Full Code of realworld-apps/realworld for AI

main a86e4d72186c cached
245 files
417.4 KB
120.3k tokens
63 symbols
2 requests
Download .txt
Showing preview only (478K chars total). Download the full file or copy to clipboard to get everything.
Repository: realworld-apps/realworld
Branch: main
Commit: a86e4d72186c
Files: 245
Total size: 417.4 KB

Directory structure:
gitextract_60zq8mk5/

├── .github/
│   ├── CODEOWNERS
│   ├── ISSUE_TEMPLATE/
│   │   ├── BUG_REPORT.yml
│   │   └── FEATURE_REQUEST.yml
│   ├── dependabot.yml
│   └── workflows/
│       ├── bruno-check.yml
│       ├── codeql.yml
│       ├── deploy-docs.yml
│       └── spammy-guardian.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── assets/
│   ├── media/
│   │   ├── conduit-logo.svg.generate.ts
│   │   └── mobile_icons/
│   │       ├── ios/
│   │       │   ├── AppIcon.appiconset/
│   │       │   │   └── Contents.json
│   │       │   └── README.md
│   │       └── watchkit/
│   │           └── AppIcon.appiconset/
│   │               └── Contents.json
│   └── theme/
│       └── styles.css
├── docs/
│   ├── .gitignore
│   ├── .vscode/
│   │   ├── extensions.json
│   │   └── launch.json
│   ├── README.md
│   ├── astro.config.mjs
│   ├── non-included/
│   │   └── LICENSES_LOGOS.md
│   ├── package.json
│   ├── src/
│   │   ├── content/
│   │   │   ├── config.ts
│   │   │   └── docs/
│   │   │       ├── community/
│   │   │       │   ├── authors.md
│   │   │       │   ├── resources.md
│   │   │       │   └── special-thanks.md
│   │   │       ├── implementation-creation/
│   │   │       │   ├── expectations.md
│   │   │       │   ├── features.md
│   │   │       │   └── introduction.md
│   │   │       ├── index.mdx
│   │   │       ├── introduction.mdx
│   │   │       └── specifications/
│   │   │           ├── backend/
│   │   │           │   ├── api-response-format.md
│   │   │           │   ├── bruno.md
│   │   │           │   ├── cors.md
│   │   │           │   ├── endpoints.md
│   │   │           │   ├── error-handling.md
│   │   │           │   ├── hurl.md
│   │   │           │   ├── introduction.md
│   │   │           │   ├── postman.md
│   │   │           │   └── tests.md
│   │   │           ├── frontend/
│   │   │           │   ├── api.md
│   │   │           │   ├── routing.md
│   │   │           │   ├── styles.md
│   │   │           │   ├── templates.md
│   │   │           │   └── tests.md
│   │   │           └── mobile-specs/
│   │   │               └── introduction.md
│   │   ├── env.d.ts
│   │   └── tailwind.css
│   └── tsconfig.json
└── specs/
    ├── api/
    │   ├── README.md
    │   ├── bruno/
    │   │   ├── articles/
    │   │   │   ├── 01-setup-register.bru
    │   │   │   ├── 02-create-article-with-tags.bru
    │   │   │   ├── 03-list-all-articles.bru
    │   │   │   ├── 04-list-by-author.bru
    │   │   │   ├── 05-list-all-articles-with-auth.bru
    │   │   │   ├── 06-list-by-author-with-auth.bru
    │   │   │   ├── 07-list-by-tag.bru
    │   │   │   ├── 08-list-articles-without-auth.bru
    │   │   │   ├── 09-get-single-article.bru
    │   │   │   ├── 10-update-article-body.bru
    │   │   │   ├── 11-verify-update-persisted.bru
    │   │   │   ├── 12-update-article-without-taglist-tags-should-be-preserved.bru
    │   │   │   ├── 13-update-article-remove-all-tags-with-empty-array.bru
    │   │   │   ├── 14-verify-tags-were-actually-removed.bru
    │   │   │   ├── 15-update-article-taglist-null-should-be-rejected.bru
    │   │   │   ├── 16-delete-article.bru
    │   │   │   └── 17-verify-deletion.bru
    │   │   ├── auth/
    │   │   │   ├── 01-register.bru
    │   │   │   ├── 02-login.bru
    │   │   │   ├── 03-get-current-user.bru
    │   │   │   ├── 04-update-user.bru
    │   │   │   ├── 05-verify-update-persisted.bru
    │   │   │   ├── 06-update-user-bio-to-empty-string-should-normalize-to-null.bru
    │   │   │   ├── 07-verify-empty-string-normalization-persisted.bru
    │   │   │   ├── 08-restore-bio-then-set-to-null.bru
    │   │   │   ├── 09-update-user-bio-to-null-should-accept-for-nullable-field.bru
    │   │   │   ├── 10-verify-null-bio-persisted.bru
    │   │   │   ├── 11-restore-bio.bru
    │   │   │   ├── 12-update-user-image.bru
    │   │   │   ├── 13-verify-image-update-persisted.bru
    │   │   │   ├── 14-update-image-to-empty-string-should-normalize-to-null.bru
    │   │   │   ├── 15-verify-image-empty-string-normalization-persisted.bru
    │   │   │   ├── 16-set-image-then-update-to-null-should-accept-for-nullable-field.bru
    │   │   │   ├── 17-put-user.bru
    │   │   │   ├── 18-verify-null-image-persisted.bru
    │   │   │   ├── 19-update-username-and-email.bru
    │   │   │   └── 20-verify-username-email-update-persisted.bru
    │   │   ├── bruno.json
    │   │   ├── collection.bru
    │   │   ├── comments/
    │   │   │   ├── 01-setup-register.bru
    │   │   │   ├── 02-setup-create-article.bru
    │   │   │   ├── 03-create-comment.bru
    │   │   │   ├── 04-list-comments.bru
    │   │   │   ├── 05-list-comments-without-auth.bru
    │   │   │   ├── 06-delete-comment.bru
    │   │   │   ├── 07-verify-deletion.bru
    │   │   │   ├── 08-selective-deletion-create-two-comments-delete-one-verify-the-other-remains.bru
    │   │   │   ├── 09-post-comments.bru
    │   │   │   ├── 10-verify-two-comments-exist.bru
    │   │   │   ├── 11-delete-the-first-comment.bru
    │   │   │   ├── 12-verify-only-the-second-comment-remains.bru
    │   │   │   └── 13-cleanup.bru
    │   │   ├── environments/
    │   │   │   └── local.bru
    │   │   ├── errors-articles/
    │   │   │   ├── 01-create-article-no-auth.bru
    │   │   │   ├── 02-get-unknown-slug.bru
    │   │   │   ├── 03-update-no-auth.bru
    │   │   │   ├── 04-delete-no-auth.bru
    │   │   │   ├── 05-get-feed-no-auth.bru
    │   │   │   ├── 06-favorite-no-auth.bru
    │   │   │   ├── 07-unfavorite-no-auth.bru
    │   │   │   ├── 08-setup-register-for-authenticated-error-tests.bru
    │   │   │   ├── 09-create-article-empty-title.bru
    │   │   │   ├── 10-create-article-empty-description.bru
    │   │   │   ├── 11-create-article-empty-body.bru
    │   │   │   ├── 12-duplicate-titles-are-allowed-each-gets-a-unique-slug.bru
    │   │   │   ├── 13-post-articles.bru
    │   │   │   ├── 14-update-unknown-slug.bru
    │   │   │   ├── 15-favorite-unknown-slug.bru
    │   │   │   ├── 16-unfavorite-unknown-slug.bru
    │   │   │   ├── 17-update-unknown-slug.bru
    │   │   │   ├── 18-delete-unknown-slug.bru
    │   │   │   ├── 19-cleanup.bru
    │   │   │   └── 20-delete-slug2.bru
    │   │   ├── errors-auth/
    │   │   │   ├── 01-register-empty-username.bru
    │   │   │   ├── 02-register-empty-email.bru
    │   │   │   ├── 03-register-empty-password.bru
    │   │   │   ├── 04-register-valid-user-for-duplicate-and-login-tests.bru
    │   │   │   ├── 05-register-duplicate-username.bru
    │   │   │   ├── 06-register-duplicate-email.bru
    │   │   │   ├── 07-login-empty-email.bru
    │   │   │   ├── 08-login-empty-password.bru
    │   │   │   ├── 09-login-wrong-password.bru
    │   │   │   ├── 10-get-user-no-auth.bru
    │   │   │   ├── 11-put-user-no-auth.bru
    │   │   │   ├── 12-update-email-to-empty-string-should-reject.bru
    │   │   │   ├── 13-update-username-to-empty-string-should-reject.bru
    │   │   │   ├── 14-update-email-to-null-should-reject.bru
    │   │   │   └── 15-update-username-to-null-should-reject.bru
    │   │   ├── errors-authorization/
    │   │   │   ├── 01-register-user-a.bru
    │   │   │   ├── 02-register-user-b.bru
    │   │   │   ├── 03-user-a-creates-article.bru
    │   │   │   ├── 04-user-b-tries-to-delete-403.bru
    │   │   │   ├── 05-user-b-tries-to-update-403.bru
    │   │   │   ├── 06-user-a-creates-a-comment-on-the-article.bru
    │   │   │   ├── 07-user-b-tries-to-delete-a-s-comment-403.bru
    │   │   │   ├── 08-verify-comment-survived-the-failed-delete.bru
    │   │   │   └── 09-cleanup-user-a-deletes-article.bru
    │   │   ├── errors-comments/
    │   │   │   ├── 01-post-comment-no-auth.bru
    │   │   │   ├── 02-delete-comment-no-auth.bru
    │   │   │   ├── 03-setup-register-create-article.bru
    │   │   │   ├── 04-post-articles.bru
    │   │   │   ├── 05-post-comment-empty-body.bru
    │   │   │   ├── 06-post-comment-on-unknown-article.bru
    │   │   │   ├── 07-get-comments-on-unknown-article.bru
    │   │   │   ├── 08-delete-comment-on-unknown-article.bru
    │   │   │   ├── 09-delete-non-existent-comment-on-existing-article.bru
    │   │   │   └── 10-cleanup.bru
    │   │   ├── errors-profiles/
    │   │   │   ├── 01-get-unknown-profile.bru
    │   │   │   ├── 02-follow-no-auth.bru
    │   │   │   ├── 03-unfollow-no-auth.bru
    │   │   │   ├── 04-setup-register-for-authenticated-404-tests.bru
    │   │   │   ├── 05-follow-unknown-user-authed.bru
    │   │   │   └── 06-unfollow-unknown-user-authed.bru
    │   │   ├── favorites/
    │   │   │   ├── 01-setup-register.bru
    │   │   │   ├── 02-setup-create-article.bru
    │   │   │   ├── 03-favorite-article.bru
    │   │   │   ├── 04-verify-favorite-persists.bru
    │   │   │   ├── 05-articles-filtered-by-favorited-username.bru
    │   │   │   ├── 06-articles-filtered-by-favorited-username-with-auth.bru
    │   │   │   ├── 07-unfavorite-article.bru
    │   │   │   ├── 08-verify-unfavorite-persists.bru
    │   │   │   └── 09-cleanup.bru
    │   │   ├── feed/
    │   │   │   ├── 01-register-main-user.bru
    │   │   │   ├── 02-register-celeb-user.bru
    │   │   │   ├── 03-feed-for-new-user-returns-empty.bru
    │   │   │   ├── 04-main-follows-celeb.bru
    │   │   │   ├── 05-celeb-creates-article-1.bru
    │   │   │   ├── 06-celeb-creates-article-2.bru
    │   │   │   ├── 07-main-checks-feed.bru
    │   │   │   ├── 08-feed-with-limit-1.bru
    │   │   │   ├── 09-feed-with-limit-1-offset-1.bru
    │   │   │   ├── 10-cleanup-delete-articles.bru
    │   │   │   ├── 11-delete-slug2.bru
    │   │   │   └── 12-cleanup-unfollow.bru
    │   │   ├── pagination/
    │   │   │   ├── 01-setup-register.bru
    │   │   │   ├── 02-create-article-1.bru
    │   │   │   ├── 03-create-article-2.bru
    │   │   │   ├── 04-list-with-limit-1-most-recent-first-so-slug2.bru
    │   │   │   ├── 05-list-with-limit-1-offset-1-second-page-so-slug1.bru
    │   │   │   ├── 06-cleanup.bru
    │   │   │   └── 07-delete-slug2.bru
    │   │   ├── profiles/
    │   │   │   ├── 01-register-main-user.bru
    │   │   │   ├── 02-register-celeb-user.bru
    │   │   │   ├── 03-get-profile-without-auth.bru
    │   │   │   ├── 04-get-profile-with-auth.bru
    │   │   │   ├── 05-follow-profile.bru
    │   │   │   ├── 06-unfollow-profile.bru
    │   │   │   └── 07-verify-unfollow-persisted.bru
    │   │   └── tags/
    │   │       ├── 01-setup-register.bru
    │   │       ├── 02-setup-create-article-with-tags.bru
    │   │       ├── 03-get-tags.bru
    │   │       └── 04-cleanup.bru
    │   ├── hurl/
    │   │   ├── articles.hurl
    │   │   ├── auth.hurl
    │   │   ├── comments.hurl
    │   │   ├── errors_articles.hurl
    │   │   ├── errors_auth.hurl
    │   │   ├── errors_authorization.hurl
    │   │   ├── errors_comments.hurl
    │   │   ├── errors_profiles.hurl
    │   │   ├── favorites.hurl
    │   │   ├── feed.hurl
    │   │   ├── pagination.hurl
    │   │   ├── profiles.hurl
    │   │   ├── run-hurl-tests.sh
    │   │   └── tags.hurl
    │   ├── hurl-to-bruno.js
    │   ├── openapi.yml
    │   ├── run-api-tests-bruno.sh
    │   └── run-api-tests-hurl.sh
    └── e2e/
        ├── SELECTORS.md
        ├── articles.spec.ts
        ├── auth.spec.ts
        ├── comments.spec.ts
        ├── error-handling.spec.ts
        ├── health.spec.ts
        ├── helpers/
        │   ├── api.ts
        │   ├── articles.ts
        │   ├── auth.ts
        │   ├── comments.ts
        │   ├── config.ts
        │   ├── debug.ts
        │   ├── profile.ts
        │   └── setup.ts
        ├── navigation.spec.ts
        ├── null-fields.spec.ts
        ├── playwright.base.ts
        ├── settings.spec.ts
        ├── social.spec.ts
        ├── url-navigation.spec.ts
        ├── user-fetch-errors.spec.ts
        └── xss-security.spec.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/CODEOWNERS
================================================



================================================
FILE: .github/ISSUE_TEMPLATE/BUG_REPORT.yml
================================================
name: 🐞 Bug report
description: Report a bug in the RealWorld project
title: '[Bug]: '
labels:
  - bug
body:
  - type: dropdown
    attributes:
      label: Relevant scope
      description: What is the scope of this request?
      options:
        - Frontend specs
        - Backend specs
        - Deployed demo
        - 'Other: describe below'
    validations:
      required: true
  - type: textarea
    attributes:
      label: Description
      description: A clear and concise description of the problem
    validations:
      required: true
  - type: markdown
    attributes:
      value: >-
        This template was generated with [Issue Forms
        Creator](https://www.issue-forms-creator.app/)


================================================
FILE: .github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml
================================================
name: 🚀 Feature request
description: Suggest a feature for RealWorld project
title: '[Feature Request]:'
body:
  - type: markdown
    attributes:
      value: '# Feature Request'
  - type: dropdown
    attributes:
      label: Relevant Scope
      description: What is the scope of this request?
      options:
        - Frontend specs
        - Backend specs
        - 'Other: describe below'
    validations:
      required: true
  - type: textarea
    attributes:
      label: Description
      description: ' <!-- ✍️--> '
    validations:
      required: true
  - type: textarea
    attributes:
      label: Describe the solution you'd like
      description: If you have a solution in mind, please describe it.
  - type: textarea
    attributes:
      label: Describe alternatives you've considered
      description: Have you considered any alternative solutions or workarounds?
  - type: markdown
    attributes:
      value: >-
        This template was generated with [Issue Forms
        Creator](https://www.issue-forms-creator.app/)


================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: github-actions
    directory: '/'
    schedule:
      interval: weekly
    open-pull-requests-limit: 10

  - package-ecosystem: npm
    directory: '/'
    schedule:
      interval: weekly
    open-pull-requests-limit: 10


================================================
FILE: .github/workflows/bruno-check.yml
================================================
name: 'Bruno Check'

on:
  push:
  pull_request:

jobs:
  bruno-check:
    name: Verify Bruno collection is up-to-date
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2

      - name: Check Bruno collection is up-to-date
        run: make bruno-check


================================================
FILE: .github/workflows/codeql.yml
================================================
name: 'CodeQL'

on:
  workflow_dispatch:
  schedule:
    - cron: '24 3 * * 3'

jobs:
  analyze:
    name: Analyze
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: read
      security-events: write

    strategy:
      fail-fast: false
      matrix:
        language: ['javascript']

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v4
        with:
          languages: ${{ matrix.language }}

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v4
        with:
          category: '/language:${{matrix.language}}'


================================================
FILE: .github/workflows/deploy-docs.yml
================================================
name: Deploy Documentation

on:
  push:
    branches: [main]
    paths:
      - 'docs/**'
      - '.github/workflows/deploy-docs.yml'
  workflow_dispatch:  # allow manual trigger

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Setup Pages
        uses: actions/configure-pages@v4

      - name: Install dependencies
        run: bun install
        working-directory: ./docs

      - name: Build documentation
        run: bun run build
        working-directory: ./docs

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v4
        with:
          path: './docs/dist'

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4


================================================
FILE: .github/workflows/spammy-guardian.yml
================================================
name: Spammy Guardian
on:
  workflow_dispatch:
    inputs:
      issueId:
        description: 'id of the issue to test againt'
        required: true
  issue_comment:
  issues:
    types: [opened]
jobs:
  spammy-guardian:
    runs-on: ubuntu-latest
    if: ${{ github.actor != 'dependabot[bot]' || github.actor != 'netlify[bot]' }}
    steps:
      - uses: kerhub/spammy-guardian@fa79bcda24df6dae5b93285e1749e59c77add4bd
        with:
          token: ${{ secrets.GITHUB_TOKEN }}


================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# Dependencies
node_modules
.pnp
.pnp.js

# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# Testing
coverage

# Turbo
.turbo

# Vercel
.vercel

# Build Outputs
.next/
.nitro/
out/
build
dist


# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Misc
.DS_Store
*.pem


# IDEs
.idea

# Python
__pycache__

# Temp files
.tmp

# Local db
dev.db


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to RealWorld

We would love for you to contribute to RealWorld and help make it even better than it is
today! As a contributor, here are the guidelines we would like you to follow:

- [Question or Problem?](#question)
- [Issues and Bugs](#issue)
- [Feature Requests](#feature)
- [Submission Guidelines](#submit)
- [Coding Rules](#rules)
- [Commit Message Guidelines](#commit)

## <a name="question"></a> Got a Question or Problem?

Do not open issues for general support questions as we want to keep GitHub issues for bug reports and feature requests.  
For open discussions, we encourage you to use the [Github Discussions][github-discussions] channels.

## <a name="issue"></a> Interested in creating Conduit for your framework?

To create an official implementation of Conduit, check out our [Github Discussions](https://github.com/realworld-apps/realworld/discussions/categories/wip-implementations) and see if anyone else has requested and/or is already working on your framework.
If not, feel free to start working on one!

Start [here][github-spec]!

## <a name="issue"></a> Found a Bug?

If you find a bug in the project, you can help us by
[submitting an issue][github-issue] to our [GitHub Repository][github]. Even better, you can
[submit a Pull Request](#submit-pr) with a fix.

## <a name="feature"></a> Missing a Feature?

You can _request_ a new feature by [submitting an issue](#submit-issue) to our GitHub
repository.

If you would like to _implement_ a new feature, please submit an issue with
a proposal for your work **FIRST**, to be sure that we can use it.
Please consider what kind of change it is:

- For a **Major Feature**, first open an issue and outline your proposal so that it can be
  discussed. This will also allow us to better coordinate our efforts, prevent duplication of work,
  and help you to craft the change so that it is successfully accepted into the project.
- **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr).

## <a name="submit"></a> Submission Guidelines

### <a name="submit-issue"></a> Submitting an Issue

Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available.

You can file new issues by selecting from our [new issue templates][github-choose] and filling out the issue template.

### <a name="submit-pr"></a> Submitting a Pull Request (PR)

Before you submit your Pull Request (PR) consider the following guidelines:

1. Search [GitHub](https://github.com/realworld-apps/realworld/pulls) for an open or closed PR
   that relates to your submission. You don't want to duplicate effort.
1. Be sure that an issue describes the problem you're fixing, or documents the design for the feature you'd like to add.
   Discussing the design up front helps to ensure that we're ready to accept your work.
1. Fork the realworld-apps/realworld repo.
1. Make your changes in a new git branch:

   ```bash
   git checkout -b my-fix-branch master
   ```

1. Create your patch.

1. Commit your changes using a descriptive commit message that follows our
   [commit message conventions](#commit).

1. Push your branch to GitHub:

   ```bash
   git push origin my-fix-branch
   ```

1. In GitHub, send a pull request to `realworld:master`.

- If we suggest changes then:

  - Make the required updates.
  - Rebase your branch and force push to your GitHub repository (this will update your Pull Request):

    ```bash
    git rebase master -i
    git push -f
    ```

That's it! Thank you for your contribution!

#### After your pull request is merged

After your pull request is merged, you can safely delete your branch and pull the changes
from the master (upstream) repository:

- Delete the remote branch on GitHub either through the GitHub web UI or your local shell as follows:

  ```bash
  git push origin --delete my-fix-branch
  ```

- Check out the master branch:

  ```bash
  git checkout master -f
  ```

- Delete the local branch:

  ```bash
  git branch -D my-fix-branch
  ```

- Update your master with the latest upstream version:

  ```bash
  git pull --ff upstream master
  ```

## <a name="commit"></a> Commit Message Guidelines

> These guidelines have been added to the project starting from February 2025

We have very precise rules over how our git commit messages can be formatted. This leads to **more
readable messages** that are easy to follow when looking through the **project history**.

### Commit Message Format

Each commit message consists of a **header**, a **body** and a **footer**. The header has a special
format that includes a **type**, a **scope** and a **subject**:

```
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
```

The **header** is mandatory and the **scope** of the header is optional.

Any line of the commit message cannot be longer 100 characters! This allows the message to be easier
to read on GitHub as well as in various git tools.

The footer should contain a [closing reference to an issue](https://help.github.com/articles/closing-issues-via-commit-messages/) if any.

Samples:

```
docs(changelog): update changelog to beta.5
```

```
fix(release): need to depend on latest ng-lib

The version in our package.json gets copied to the one we publish, and users need the latest of these.
```

### Type

Must be one of the following:

- **docs**: Documentation only changes
- **feat**: A new feature
- **fix**: A bug fix

### Scope

The scope should be the name of the npm package affected (as perceived by the person reading the changelog generated from commit messages).

The following is the list of supported scopes:

- **specs**
- **project**

### Subject

The subject contains a succinct description of the change:

- use the imperative, present tense: "change" not "changed" nor "changes"
- don't capitalize the first letter
- no dot (.) at the end

### Body

Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes".
The body should include the motivation for the change and contrast this with previous behavior.

### Footer

The footer should contain any information about **Breaking Changes** and is also the place to
reference GitHub issues that this commit **Closes**.

**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this.

Samples :

```
Close #394
```

```
BREAKING CHANGE:
change login route to /users/login
```

[github]: https://github.com/realworld-apps/realworld
[github-issue]: https://github.com/realworld-apps/realworld/issues/new?assignees=&labels=bug&template=---bug-report.md&title=
[github-feature]: https://github.com/realworld-apps/realworld/issues/new?assignees=&labels=enhancement&template=---feature-request.md&title=
[github-choose]: https://github.com/realworld-apps/realworld/issues/new/choose
[github-discussions]: https://github.com/realworld-apps/realworld/discussions
[github-spec]: https://github.com/realworld-apps/realworld/tree/master/spec


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2021 Thinkster
Copyright (c) 2026 c4ffein

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---

Note: Third-party framework logos in assets/media/frameworks.svg are NOT covered
by this license. See docs/non-included/LICENSES_LOGOS.md for their respective
licenses and attribution.


================================================
FILE: Makefile
================================================
.PHONY: help \
	bruno-generate \
	bruno-check \
	documentation-setup \
	documentation-dev \
	documentation-dev-host \
	documentation-build \
	documentation-preview \
	documentation-clean

help:
	@echo "Bruno Collection:"
	@echo "  bruno-generate"
	@echo "  bruno-check"
	@echo ""
	@echo "Documentation:"
	@echo "  documentation-setup"
	@echo "  documentation-dev"
	@echo "  documentation-dev-host"
	@echo "  documentation-build"
	@echo "  documentation-preview"
	@echo "  documentation-clean"

########################
# Bruno Collection

bruno-generate:
	bun specs/api/hurl-to-bruno.js

bruno-check:
	bun specs/api/hurl-to-bruno.js --check

########################
# Documentation

documentation-setup:
	cd docs && bun install

documentation-dev:
	cd docs && bun run dev

documentation-dev-host:
	cd docs && bun run dev --host

documentation-build:
	cd docs && bun run build

documentation-preview:
	cd docs && bun run preview

documentation-clean:
	rm -rf docs/.astro docs/dist docs/node_modules


================================================
FILE: README.md
================================================
![RealWorld Example Applications](assets/media/realworld-dual-mode.png)

<p align="center" style="margin-top: 30px;">
  <img src="assets/media/frameworks.svg" alt="Frontend and Backend Frameworks" width="720"/>
</p>

### See how [_the exact same_ Medium.com clone](https://demo.realworld.show) is built using different [frontends](https://codebase.show/projects/realworld?category=frontend) and [backends](https://codebase.show/projects/realworld?category=backend)

You can combine any frontend with any backend, because **they all adhere to the same API spec**

While most "todo" demos provide an excellent cursory glance at a framework's capabilities, they typically don't convey the knowledge & perspective required to actually build _real_ applications with it.

**RealWorld** solves this by allowing you to choose any frontend (React, Angular, & more) and any backend (Node, Django, & more).

_Read the [full blog post announcing RealWorld on Medium.](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5)_

Join us on [GitHub Discussions!](https://github.com/realworld-apps/realworld/discussions) 🎉

# Implementations

Over 100 implementations have been created using various languages, libraries, and frameworks.

Explore them on [**CodebaseShow**](https://codebase.show/projects/realworld).

## Spec-compliant backends

These backends pass the full [API spec test suite](specs/api/):

- [**Nitro + Prisma + Zod**](https://github.com/realworld-apps/nitro-prisma-zod-realworld-example-app) — TypeScript
- [**Django Ninja**](https://github.com/c4ffein/realworld-django-ninja) — Python

# Create a new implementation

[**Create a new implementation >>>**](https://docs.realworld.show/implementation-creation/introduction)

Or you can [view upcoming implementations (WIPs)](https://github.com/realworld-apps/realworld/discussions/categories/wip-implementations).

# Learn more

- [Documentation introduction](https://docs.realworld.show/introduction/)
- Every tutorial is built against the same [API spec](specs/api/) to ensure modularity of every frontend & backend
- A shared [CSS theme](assets/theme/styles.css) is provided to build frontend implementations with identical UI/UX
- A shared [E2E test suite](specs/e2e/) is available to validate frontend implementations
- There is a hosted version of the backend API available for public usage at [api.realworld.show](https://api.realworld.show) (with strong account isolation), no API keys are required
- There is an angular frontend plugged to this backend available at [demo.realworld.show](https://demo.realworld.show)
- Interested in creating a new RealWorld stack? View our [starter guide & spec](https://docs.realworld.show/implementation-creation/introduction)

# Logo Attribution

See [LICENSES_LOGOS.md](docs/non-included/LICENSES_LOGOS.md) for framework logo licensing and attribution details.

# Active Maintainers

- **[c4ffein](https://github.com/c4ffein) - Maintainer** - currently maintains the [demo website](https://demo.realworld.show)
- **[Manuel Vila](https://github.com/mvila) - Maintainer** - creator of the [Layr framework](https://layrjs.com) and the [CodebaseShow website](https://codebase.show/)


================================================
FILE: assets/media/conduit-logo.svg.generate.ts
================================================
import opentype from 'opentype.js';
import { mkdirSync } from 'fs';

process.chdir(import.meta.dir);

const FONT_URL = 'https://fonts.gstatic.com/s/caudex/v19/esDT311QOP6BJUrwdteUkp8G.ttf';
const TMP_DIR = '.tmp';
const FONT_PATH = `${TMP_DIR}/caudex-bold.ttf`;

// Ensure .tmp directory exists and download font if needed
mkdirSync(TMP_DIR, { recursive: true });
const fontFile = Bun.file(FONT_PATH);
if (!(await fontFile.exists())) {
  console.log('Downloading Caudex Bold...');
  const res = await fetch(FONT_URL);
  await Bun.write(FONT_PATH, res);
}

const font = opentype.loadSync(FONT_PATH);
const text = 'Conduit';
const fontSize = 48;

const path = font.getPath(text, 0, 0, fontSize);
const bb = path.getBoundingBox();

const padding = 2;
const x = bb.x1 - padding;
const y = bb.y1 - padding;
const width = bb.x2 - bb.x1 + padding * 2;
const height = bb.y2 - bb.y1 + padding * 2;

const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x} ${y} ${width} ${height}" fill="#222">
  ${path.toSVG(2)}
</svg>`;

const outPath = 'conduit-logo.svg';
await Bun.write(outPath, svg);
console.log(`Written to ${outPath} (${width.toFixed(1)} x ${height.toFixed(1)})`);


================================================
FILE: assets/media/mobile_icons/ios/AppIcon.appiconset/Contents.json
================================================
{
  "images": [
    {
      "idiom": "iphone",
      "size": "20x20",
      "scale": "2x",
      "filename": "Icon-App-20x20@2x.png"
    },
    {
      "idiom": "iphone",
      "size": "20x20",
      "scale": "3x",
      "filename": "Icon-App-20x20@3x.png"
    },
    {
      "idiom": "iphone",
      "size": "29x29",
      "scale": "1x",
      "filename": "Icon-App-29x29@1x.png"
    },
    {
      "idiom": "iphone",
      "size": "29x29",
      "scale": "2x",
      "filename": "Icon-App-29x29@2x.png"
    },
    {
      "idiom": "iphone",
      "size": "29x29",
      "scale": "3x",
      "filename": "Icon-App-29x29@3x.png"
    },
    {
      "idiom": "iphone",
      "size": "40x40",
      "scale": "1x",
      "filename": "Icon-App-40x40@1x.png"
    },
    {
      "idiom": "iphone",
      "size": "40x40",
      "scale": "2x",
      "filename": "Icon-App-40x40@2x.png"
    },
    {
      "idiom": "iphone",
      "size": "40x40",
      "scale": "3x",
      "filename": "Icon-App-40x40@3x.png"
    },
    {
      "idiom": "iphone",
      "size": "57x57",
      "scale": "1x",
      "filename": "Icon-App-57x57@1x.png"
    },
    {
      "idiom": "iphone",
      "size": "57x57",
      "scale": "2x",
      "filename": "Icon-App-57x57@2x.png"
    },
    {
      "idiom": "iphone",
      "size": "60x60",
      "scale": "1x",
      "filename": "Icon-App-60x60@1x.png"
    },
    {
      "idiom": "iphone",
      "size": "60x60",
      "scale": "2x",
      "filename": "Icon-App-60x60@2x.png"
    },
    {
      "idiom": "iphone",
      "size": "60x60",
      "scale": "3x",
      "filename": "Icon-App-60x60@3x.png"
    },
    {
      "idiom": "iphone",
      "size": "76x76",
      "scale": "1x",
      "filename": "Icon-App-76x76@1x.png"
    },
    {
      "idiom": "ipad",
      "size": "20x20",
      "scale": "1x",
      "filename": "Icon-App-20x20@1x.png"
    },
    {
      "idiom": "ipad",
      "size": "20x20",
      "scale": "2x",
      "filename": "Icon-App-20x20@2x.png"
    },
    {
      "idiom": "ipad",
      "size": "29x29",
      "scale": "1x",
      "filename": "Icon-App-29x29@1x.png"
    },
    {
      "idiom": "ipad",
      "size": "29x29",
      "scale": "2x",
      "filename": "Icon-App-29x29@2x.png"
    },
    {
      "idiom": "ipad",
      "size": "40x40",
      "scale": "1x",
      "filename": "Icon-App-40x40@1x.png"
    },
    {
      "idiom": "ipad",
      "size": "40x40",
      "scale": "2x",
      "filename": "Icon-App-40x40@2x.png"
    },
    {
      "size": "50x50",
      "idiom": "ipad",
      "filename": "Icon-Small-50x50@1x.png",
      "scale": "1x"
    },
    {
      "size": "50x50",
      "idiom": "ipad",
      "filename": "Icon-Small-50x50@2x.png",
      "scale": "2x"
    },
    {
      "idiom": "ipad",
      "size": "72x72",
      "scale": "1x",
      "filename": "Icon-App-72x72@1x.png"
    },
    {
      "idiom": "ipad",
      "size": "72x72",
      "scale": "2x",
      "filename": "Icon-App-72x72@2x.png"
    },
    {
      "idiom": "ipad",
      "size": "76x76",
      "scale": "1x",
      "filename": "Icon-App-76x76@1x.png"
    },
    {
      "idiom": "ipad",
      "size": "76x76",
      "scale": "2x",
      "filename": "Icon-App-76x76@2x.png"
    },
    {
      "idiom": "ipad",
      "size": "76x76",
      "scale": "3x",
      "filename": "Icon-App-76x76@3x.png"
    },
    {
      "idiom": "ipad",
      "size": "83.5x83.5",
      "scale": "2x",
      "filename": "Icon-App-83.5x83.5@2x.png"
    }
  ],
  "info": {
    "version": 1,
    "author": "makeappicon"
  }
}


================================================
FILE: assets/media/mobile_icons/ios/README.md
================================================
## iTunesArtwork & iTunesArtwork@2x (App Icon) file extension:

PNG extension is prepended to these two files -

While Apple suggested to omit the extension for these files,
the '.png' extension is actually required for iTunesConnect submission.

This is done for you so you don't have to.

However, for Ad_hoc or Enterprise distribution, the extension should be removed
from the files before adding to XCode to avoid error.

refs: https://developer.apple.com/library/ios/qa/qa1686/_index.html

## iTunesArtwork & iTunesArtwork@2x (App Icon) transparency handling:

As images with alpha channels or transparencies cannot be set as an application's icon on
iTunesConnect, all transparent pixels in your images will be converted into
solid blacks.

To achieve the best result, you're advised to adjust the transparency settings
in your source files before converting them with makeAppIcon.

refs: https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/MobileHIG/AppIcons.html


================================================
FILE: assets/media/mobile_icons/watchkit/AppIcon.appiconset/Contents.json
================================================
{
  "images": [
    {
      "size": "24x24",
      "idiom": "watch",
      "scale": "2x",
      "filename": "Icon-24@2x.png",
      "role": "notificationCenter",
      "subtype": "38mm"
    },
    {
      "size": "27.5x27.5",
      "idiom": "watch",
      "scale": "2x",
      "filename": "Icon-27.5@2x.png",
      "role": "notificationCenter",
      "subtype": "42mm"
    },
    {
      "size": "29x29",
      "idiom": "watch",
      "scale": "2x",
      "filename": "Icon-29@2x.png",
      "role": "companionSettings"
    },
    {
      "size": "29x29",
      "idiom": "watch",
      "scale": "3x",
      "filename": "Icon-29@3x.png",
      "role": "companionSettings"
    },
    {
      "size": "40x40",
      "idiom": "watch",
      "scale": "2x",
      "filename": "Icon-40@2x.png",
      "role": "appLauncher",
      "subtype": "38mm"
    },
    {
      "size": "44x44",
      "idiom": "watch",
      "scale": "2x",
      "filename": "Icon-44@2x.png",
      "role": "longLook",
      "subtype": "42mm"
    },
    {
      "size": "86x86",
      "idiom": "watch",
      "scale": "2x",
      "filename": "Icon-86@2x.png",
      "role": "quickLook",
      "subtype": "38mm"
    },
    {
      "size": "98x98",
      "idiom": "watch",
      "scale": "2x",
      "filename": "Icon-98@2x.png",
      "role": "quickLook",
      "subtype": "42mm"
    }
  ],
  "info": {
    "version": 1,
    "author": "makeappicon"
  }
}


================================================
FILE: assets/theme/styles.css
================================================
/*
 * ============================================================================
 * Conduit Minimal CSS v4
 * Only includes classes actually used in this codebase
 * ============================================================================
 *
 * USED CLASSES:
 * Layout: container, row, col-xs-12, col-md-{3,6,8,9,10,12}, offset-md-{1,2,3}
 * Nav: navbar, navbar-{light,brand,nav}, nav, nav-{pills,item,link}, outline-active
 * Buttons: btn, btn-{sm,lg,primary,secondary}, btn-outline-{primary,secondary,danger}
 * Forms: form-group, form-control, form-control-lg
 * Cards: card, card-{block,footer,text}, comment-form
 * Pagination: pagination, page-item, page-link
 * Tags: tag-list, tag-default, tag-pill, tag-outline
 * Pages: home-page, auth-page, settings-page, editor-page, profile-page, article-page
 * Article: article-preview, article-meta, article-content, article-actions, articles-toggle
 * Comments: comment-author, comment-author-img, mod-options, date-posted
 * User: user-info, user-img, user-pic
 * Misc: banner, logo-font, sidebar, feed-toggle, error-messages, counter,
 *       preview-link, author, date, info, attribution, active, disabled,
 *       pull-xs-right, text-xs-center, empty-feed-message
 *
 * NOTE: auth-page, settings-page, and empty-feed-message are semantic hooks
 * used in templates but require no dedicated styles.
 *
 * ============================================================================
 */

:root {
  --brand: #33aa44;
  --brand-hover: #2b8e3a;
  --brand-active: #237630;
  --brand-dark: #237630;
  --brand-light: #7dd88b;

  --danger: #b85c5c;
  --danger-light: #d7a3a3;

  --secondary: #ccc;
  --secondary-hover: #b3b3b3;
  --secondary-border: #adadad;

  --text: #373a3c;
  --text-muted: #999;
  --text-light: #bbb;
  --text-lighter: #aaa;
  --text-disabled: #818a91;

  --border: #eee;
  --bg-light: #f3f3f3;
  --input-focus: var(--brand);

  --content-width: 720px;
  --content-breakpoint: 1080px;
}

/* ============================================================================
 * CSS RESET & BASE
 * ============================================================================ */

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html {
  font-size: 16px;
  -webkit-tap-highlight-color: transparent;
}

body {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  font-family: 'Source Sans Pro', sans-serif;
  font-size: 1rem;
  line-height: 1.5;
  color: var(--text);
  background-color: #fff;
}

/* Main content area grows to push footer down */
app-root {
  display: flex;
  flex-direction: column;
  flex: 1;
}

app-root > *:not(app-layout-footer) {
  flex-shrink: 0;
}

app-root > app-layout-footer,
app-root > footer {
  margin-top: auto;
}

ol,
ul {
  list-style: none;
}

hr {
  margin-top: 1rem;
  margin-bottom: 1rem;
  border: 0;
  border-top: 1px solid #eee;
}

a {
  color: var(--brand);
  text-decoration: none;
  background-color: transparent;
  transition: color 0.15s ease;
}

a:focus,
a:hover {
  color: var(--brand-dark);
  text-decoration: underline;
}

a:focus {
  outline: thin dotted;
  outline: 5px auto -webkit-focus-ring-color;
  outline-offset: -2px;
}

a:not([href]) {
  color: inherit;
  text-decoration: none;
}

a:not([href]):focus,
a:not([href]):hover {
  color: inherit;
  text-decoration: none;
}

a:not([href]):focus {
  outline: none;
}

img {
  vertical-align: middle;
  border: 0;
}

h1,
h2,
h3,
h4,
h5,
h6 {
  margin-bottom: 0.5rem;
  font-weight: 500;
  line-height: 1.1;
}

h1 {
  font-size: 2.5rem;
}
h2 {
  font-size: 2rem;
}
h3 {
  font-size: 1.75rem;
}
h4 {
  font-size: 1.5rem;
}
h5 {
  font-size: 1.25rem;
}
h6 {
  font-size: 1rem;
}

p {
  margin-bottom: 1rem;
}

.logo-font {
  font-family: 'Lora', serif;
}

.navbar-logo {
  height: 1.25rem;
  vertical-align: middle;
}

.banner-logo {
  height: 3rem;
}

.footer-logo {
  height: 1rem;
  display: block;
}

button,
input,
optgroup,
select,
textarea {
  font: inherit;
  color: inherit;
  margin: 0;
}

button {
  overflow: visible;
}

button,
select {
  text-transform: none;
}

button,
html input[type='button'],
input[type='reset'],
input[type='submit'] {
  -webkit-appearance: button;
  cursor: pointer;
}

button[disabled],
html input[disabled] {
  cursor: default;
}

button::-moz-focus-inner,
input::-moz-focus-inner {
  border: 0;
  padding: 0;
}

input {
  line-height: normal;
}

textarea {
  overflow: auto;
  resize: vertical;
}

input,
button,
select,
textarea {
  line-height: inherit;
  border-radius: 0;
}

a,
area,
button,
[role='button'],
input,
label,
select,
summary,
textarea {
  touch-action: manipulation;
}

button:focus {
  outline: 1px dotted;
  outline: 5px auto -webkit-focus-ring-color;
}

fieldset {
  min-width: 0;
  padding: 0;
  margin: 0;
  border: 0;
}

/* ============================================================================
 * LAYOUT: CONTAINER & GRID
 * ============================================================================ */

.container {
  margin-left: auto;
  margin-right: auto;
  padding-left: 15px;
  padding-right: 15px;
}

@media (min-width: 544px) {
  .container {
    max-width: 576px;
  }
}

@media (min-width: 768px) {
  .container {
    max-width: 720px;
  }
}

@media (min-width: 992px) {
  .container {
    max-width: 940px;
  }
}

@media (min-width: 1200px) {
  .container {
    max-width: 1140px;
  }
}

.row {
  display: flex;
  flex-wrap: wrap;
  margin-left: -15px;
  margin-right: -15px;
}

.col-xs-12 {
  position: relative;
  min-height: 1px;
  padding-right: 15px;
  padding-left: 15px;
  flex: 0 0 100%;
  max-width: 100%;
}

@media (min-width: 768px) {
  .col-md-3 {
    position: relative;
    min-height: 1px;
    padding-right: 15px;
    padding-left: 15px;
    flex: 0 0 25%;
    max-width: 25%;
  }
  .col-md-6 {
    position: relative;
    min-height: 1px;
    padding-right: 15px;
    padding-left: 15px;
    flex: 0 0 50%;
    max-width: 50%;
  }
  .col-md-8 {
    position: relative;
    min-height: 1px;
    padding-right: 15px;
    padding-left: 15px;
    flex: 0 0 66.66667%;
    max-width: 66.66667%;
  }
  .col-md-9 {
    position: relative;
    min-height: 1px;
    padding-right: 15px;
    padding-left: 15px;
    flex: 0 0 75%;
    max-width: 75%;
  }
  .col-md-10 {
    position: relative;
    min-height: 1px;
    padding-right: 15px;
    padding-left: 15px;
    flex: 0 0 83.33333%;
    max-width: 83.33333%;
  }
  .col-md-12 {
    position: relative;
    min-height: 1px;
    padding-right: 15px;
    padding-left: 15px;
    flex: 0 0 100%;
    max-width: 100%;
  }
  .offset-md-1 {
    margin-left: 8.33333%;
  }
  .offset-md-2 {
    margin-left: 16.66667%;
  }
  .offset-md-3 {
    margin-left: 25%;
  }
}

/* ============================================================================
 * FORMS
 * ============================================================================ */

.form-control {
  display: block;
  width: 100%;
  padding: 0.5rem 0.75rem;
  font-size: 1rem;
  line-height: 1.25;
  color: #55595c;
  background-color: #fff;
  background-image: none;
  background-clip: padding-box;
  border: 1px solid #eee;
  border-radius: 0.5rem;
  transition: border-color 0.15s ease;
}

.form-control:focus {
  border-color: var(--input-focus);
  outline: none;
}

.form-control::placeholder {
  color: var(--text-muted);
  opacity: 1;
}

.form-control:disabled,
.form-control[readonly] {
  background-color: #eceeef;
  opacity: 1;
}

.form-control:disabled {
  cursor: not-allowed;
}

.form-control-lg {
  padding: 0.75rem 1.5rem;
  font-size: 1.25rem;
  border-radius: 0.625rem;
}

.form-group {
  margin-bottom: 1rem;
}

/* ============================================================================
 * BUTTONS
 * ============================================================================ */

.btn {
  display: inline-block;
  font-weight: normal;
  line-height: 1.25;
  text-align: center;
  white-space: nowrap;
  vertical-align: middle;
  cursor: pointer;
  user-select: none;
  border: 1px solid transparent;
  padding: 0.5rem 1rem;
  font-size: 1rem;
  border-radius: 0.5rem;
  transition:
    color 0.15s ease,
    background-color 0.15s ease,
    border-color 0.15s ease,
    box-shadow 0.15s ease;
}

.btn:focus,
.btn.focus,
.btn:active:focus,
.btn:active.focus,
.btn.active:focus,
.btn.active.focus {
  outline: thin dotted;
  outline: 5px auto -webkit-focus-ring-color;
  outline-offset: -2px;
}

.btn:focus,
.btn:hover {
  text-decoration: none;
}

.btn.focus {
  text-decoration: none;
}

.btn:active,
.btn.active {
  background-image: none;
  outline: 0;
}

.btn.disabled,
.btn:disabled {
  cursor: not-allowed;
  opacity: 0.65;
}

a.btn.disabled,
fieldset[disabled] a.btn {
  pointer-events: none;
}

/* btn-primary */
.btn-primary {
  color: #fff;
  background-color: var(--brand);
  border-color: var(--brand);
}

.btn-primary:hover {
  color: #fff;
  background-color: var(--brand-hover);
  border-color: var(--brand-hover);
}

.btn-primary:focus,
.btn-primary.focus {
  color: #fff;
  background-color: var(--brand-hover);
  border-color: var(--brand-hover);
}

.btn-primary:active,
.btn-primary.active {
  color: #fff;
  background-color: var(--brand-hover);
  border-color: var(--brand-hover);
  background-image: none;
}

.btn-primary:active:hover,
.btn-primary:active:focus,
.btn-primary:active.focus,
.btn-primary.active:hover,
.btn-primary.active:focus,
.btn-primary.active.focus {
  color: #fff;
  background-color: var(--brand-active);
  border-color: var(--brand-active);
}

.btn-primary.disabled:focus,
.btn-primary.disabled.focus,
.btn-primary:disabled:focus,
.btn-primary:disabled.focus {
  background-color: var(--brand);
  border-color: var(--brand);
}

.btn-primary.disabled:hover,
.btn-primary:disabled:hover {
  background-color: var(--brand);
  border-color: var(--brand);
}

/* btn-secondary */
.btn-secondary {
  color: #fff;
  background-color: var(--secondary);
  border-color: var(--secondary);
}

.btn-secondary:hover {
  color: #fff;
  background-color: var(--secondary-hover);
  border-color: var(--secondary-border);
}

.btn-secondary:focus,
.btn-secondary.focus {
  color: #fff;
  background-color: var(--secondary-hover);
  border-color: var(--secondary-border);
}

.btn-secondary:active,
.btn-secondary.active {
  color: #fff;
  background-color: var(--secondary-hover);
  border-color: var(--secondary-border);
  background-image: none;
}

.btn-secondary.disabled:focus,
.btn-secondary.disabled.focus,
.btn-secondary:disabled:focus,
.btn-secondary:disabled.focus {
  background-color: var(--secondary);
  border-color: var(--secondary);
}

.btn-secondary.disabled:hover,
.btn-secondary:disabled:hover {
  background-color: var(--secondary);
  border-color: var(--secondary);
}

/* btn-outline-primary */
.btn-outline-primary {
  color: var(--brand);
  background-image: none;
  background-color: transparent;
  border-color: var(--brand);
}

.btn-outline-primary:hover {
  color: #fff;
  background-color: var(--brand);
  border-color: var(--brand);
}

.btn-outline-primary:focus,
.btn-outline-primary.focus {
  color: #fff;
  background-color: var(--brand);
  border-color: var(--brand);
}

.btn-outline-primary:active,
.btn-outline-primary.active {
  color: #fff;
  background-color: var(--brand);
  border-color: var(--brand);
}

.btn-outline-primary.disabled:focus,
.btn-outline-primary.disabled.focus,
.btn-outline-primary:disabled:focus,
.btn-outline-primary:disabled.focus {
  border-color: var(--brand-light);
}

.btn-outline-primary.disabled:hover,
.btn-outline-primary:disabled:hover {
  border-color: var(--brand-light);
}

/* btn-outline-secondary */
.btn-outline-secondary {
  color: var(--secondary);
  background-image: none;
  background-color: transparent;
  border-color: var(--secondary);
}

.btn-outline-secondary:hover {
  color: #fff;
  background-color: var(--secondary);
  border-color: var(--secondary);
}

.btn-outline-secondary:focus,
.btn-outline-secondary.focus {
  color: #fff;
  background-color: var(--secondary);
  border-color: var(--secondary);
}

.btn-outline-secondary:active,
.btn-outline-secondary.active {
  color: #fff;
  background-color: var(--secondary);
  border-color: var(--secondary);
}

.btn-outline-secondary.disabled:focus,
.btn-outline-secondary.disabled.focus,
.btn-outline-secondary:disabled:focus,
.btn-outline-secondary:disabled.focus {
  border-color: white;
}

.btn-outline-secondary.disabled:hover,
.btn-outline-secondary:disabled:hover {
  border-color: white;
}

/* btn-outline-danger */
.btn-outline-danger {
  color: var(--danger);
  background-image: none;
  background-color: transparent;
  border-color: var(--danger);
}

.btn-outline-danger:hover {
  color: #fff;
  background-color: var(--danger);
  border-color: var(--danger);
}

.btn-outline-danger:focus,
.btn-outline-danger.focus {
  color: #fff;
  background-color: var(--danger);
  border-color: var(--danger);
}

.btn-outline-danger:active,
.btn-outline-danger.active {
  color: #fff;
  background-color: var(--danger);
  border-color: var(--danger);
}

.btn-outline-danger.disabled:focus,
.btn-outline-danger.disabled.focus,
.btn-outline-danger:disabled:focus,
.btn-outline-danger:disabled.focus {
  border-color: var(--danger-light);
}

.btn-outline-danger.disabled:hover,
.btn-outline-danger:disabled:hover {
  border-color: var(--danger-light);
}

/* Button sizes */
.btn-lg {
  padding: 0.75rem 1.5rem;
  font-size: 1.25rem;
  border-radius: 0.625rem;
}

.btn-sm {
  padding: 0.25rem 0.5rem;
  font-size: 0.875rem;
  border-radius: 0.375rem;
}

/* ============================================================================
 * NAVIGATION
 * ============================================================================ */

.nav {
  padding-left: 0;
  margin-bottom: 0;
  list-style: none;
}

.nav-link {
  display: inline-block;
  transition:
    color 0.15s ease,
    background-color 0.15s ease;
}

.nav-link:focus,
.nav-link:hover {
  text-decoration: none;
}

.nav-link.disabled,
.nav-link.disabled:focus,
.nav-link.disabled:hover {
  color: var(--text-disabled);
  cursor: not-allowed;
  background-color: transparent;
}

.nav-pills {
  display: flex;
  flex-wrap: wrap;
}

.nav-pills .nav-item + .nav-item {
  margin-left: 0.2rem;
}

.nav-pills .nav-link {
  display: block;
  padding: 0.5em 1em;
  border-radius: 0.5rem;
}

.nav-pills .nav-link.active,
.nav-pills .nav-link.active:focus,
.nav-pills .nav-link.active:hover {
  color: #fff;
  cursor: default;
  background-color: var(--brand);
}

/* Navbar */
.navbar {
  position: relative;
  padding: 0.5rem 1rem;
}

.navbar > .container {
  display: flex;
  align-items: center;
}

.navbar-brand {
  padding-top: 0;
  padding-bottom: 0.25rem;
  margin-right: 2rem;
  font-family: 'Lora', serif;
  font-size: 1.5rem;
  color: #222;
}

.navbar-brand:focus,
.navbar-brand:hover {
  text-decoration: none;
  color: #222;
}

.navbar-nav {
  display: flex;
  align-items: center;
  margin-left: auto;
}

.navbar-nav .nav-link {
  display: block;
  padding-top: 0.425rem;
  padding-bottom: 0.425rem;
}

.navbar-nav .nav-link + .nav-link {
  margin-left: 1rem;
}

.navbar-nav .nav-item + .nav-item {
  margin-left: 1rem;
}

.navbar-light .navbar-brand,
.navbar-light .navbar-brand:focus,
.navbar-light .navbar-brand:hover {
  color: #222;
}

.navbar-light .navbar-nav .nav-link {
  color: rgba(0, 0, 0, 0.3);
}

.navbar-light .navbar-nav .nav-link:focus,
.navbar-light .navbar-nav .nav-link:hover {
  color: rgba(0, 0, 0, 0.6);
}

.navbar-light .navbar-nav .active > .nav-link,
.navbar-light .navbar-nav .active > .nav-link:focus,
.navbar-light .navbar-nav .active > .nav-link:hover,
.navbar-light .navbar-nav .nav-link.active,
.navbar-light .navbar-nav .nav-link.active:focus,
.navbar-light .navbar-nav .nav-link.active:hover {
  color: rgba(0, 0, 0, 0.8);
}

.nav-link .user-pic {
  height: 26px;
  border-radius: 50px;
  float: left;
  margin-right: 5px;
}

.nav-link:hover {
  text-decoration: none;
}

.nav-signup {
  color: #fff !important;
  background: var(--brand);
  border: 1px solid var(--brand);
  border-radius: 99em;
  padding: 5px 12px !important;
  font-size: 0.8125rem;
  line-height: 1.25rem;
  transition:
    background 200ms ease,
    border-color 200ms ease;
}

.nav-signup:hover,
.nav-signup:focus {
  color: #fff !important;
  background: var(--brand-hover);
  border-color: var(--brand-hover);
}

.nav-pills.outline-active .nav-link {
  border-radius: 0;
  border: none;
  border-bottom: 2px solid transparent;
  background: transparent;
  color: var(--text-lighter);
}

.nav-pills.outline-active .nav-link:hover {
  color: #555;
}

.nav-pills.outline-active .nav-link.active {
  background: #fff;
  border-bottom: 2px solid var(--brand);
  color: var(--brand);
}

/* ============================================================================
 * CARDS
 * ============================================================================ */

.card {
  position: relative;
  display: block;
  margin-bottom: 0.75rem;
  background-color: #fff;
  border-radius: 0.75rem;
  border: 1px solid #eee;
  box-shadow:
    0 1px 3px rgba(0, 0, 0, 0.08),
    0 1px 2px rgba(0, 0, 0, 0.06);
  transition: box-shadow 0.2s ease;
}

.card-block {
  padding: 1.25rem;
}

.card-block::after {
  content: '';
  display: table;
  clear: both;
}

.card-text:last-child {
  margin-bottom: 0;
}

.card-footer {
  padding: 0.75rem 1.25rem;
  background-color: #f5f5f5;
  border-top: 1px solid #eee;
}

.card-footer::after {
  content: '';
  display: table;
  clear: both;
}

.card-footer:last-child {
  border-radius: 0 0 0.75rem 0.75rem;
}

/* ============================================================================
 * PAGINATION
 * ============================================================================ */

.pagination {
  display: inline-block;
  padding-left: 0;
  margin-top: 1rem;
  margin-bottom: 1rem;
  border-radius: 0.5rem;
}

.page-item {
  display: inline;
}

.page-item:first-child .page-link {
  margin-left: 0;
  border-bottom-left-radius: 0.5rem;
  border-top-left-radius: 0.5rem;
}

.page-item:last-child .page-link {
  border-bottom-right-radius: 0.5rem;
  border-top-right-radius: 0.5rem;
}

.page-item.active .page-link,
.page-item.active .page-link:focus,
.page-item.active .page-link:hover {
  z-index: 2;
  color: #222;
  cursor: default;
  background-color: #eee;
  border-color: #eee;
}

.page-item.disabled .page-link,
.page-item.disabled .page-link:focus,
.page-item.disabled .page-link:hover {
  color: var(--text-disabled);
  pointer-events: none;
  cursor: not-allowed;
  background-color: #fff;
  border-color: var(--border);
}

.page-link {
  position: relative;
  float: left;
  padding: 0.5rem 0.75rem;
  margin-left: -1px;
  color: var(--brand);
  text-decoration: none;
  background-color: #fff;
  border: 1px solid var(--border);
  transition:
    color 0.15s ease,
    background-color 0.15s ease;
}

.page-link:focus,
.page-link:hover {
  color: var(--brand-dark);
  background-color: #eceeef;
  border-color: var(--border);
}

/* ============================================================================
 * TAGS
 * ============================================================================ */

.tag-pill {
  padding-right: 0.6em;
  padding-left: 0.6em;
  border-radius: 10rem;
}

.tag-default {
  background-color: transparent;
  color: #222;
  border: 1px solid #eee;
  border-radius: 100px;
  font-size: 0.8rem;
  line-height: 1.1rem;
  padding: 0.35rem 0.75rem;
  white-space: nowrap;
  margin-right: 0.5rem;
  margin-bottom: 0.5rem;
  display: inline-block;
  transition: background 300ms ease;
}

.tag-default:hover {
  text-decoration: none;
  background-color: #eee;
}

.tag-default[href]:focus,
.tag-default[href]:hover {
  background-color: #eee;
}

.tag-default.tag-outline {
  border: 1px solid var(--border);
  color: var(--text-lighter);
  background: none;
}

ul.tag-list {
  display: inline-block;
}

ul.tag-list li {
  display: inline-block;
}

/* ============================================================================
 * UTILITIES
 * ============================================================================ */

.pull-xs-right {
  float: right !important;
}

.text-xs-center {
  text-align: center !important;
}

/* ============================================================================
 * FOOTER
 * ============================================================================ */

footer {
  background: #fff;
  border-top: 1px solid #eee;
  margin-top: 42px;
  padding: 1rem 0;
  width: 100%;
}

footer .logo-font {
  color: #222;
}

footer a {
  color: #222;
}

footer .attribution {
  margin-left: 10px;
  font-size: 0.8rem;
  color: var(--text-light);
  font-weight: 300;
}

/* ============================================================================
 * ERROR MESSAGES
 * ============================================================================ */

.error-messages {
  color: var(--danger);
  font-weight: bold;
  padding-left: 2.5rem;
  margin-bottom: 1rem;
  list-style: disc;
}

/* ============================================================================
 * BANNER
 * ============================================================================ */

.banner {
  color: var(--text);
  background: #fff;
  border-top: 1px solid #eee;
  border-bottom: 1px solid #eee;
  padding: 2rem;
  margin-bottom: 2rem;
}

.banner h1 {
  margin-bottom: 0;
}

.container.page {
  margin-top: 1.5rem;
}

/* ============================================================================
 * ARTICLE PREVIEW & META
 * ============================================================================ */

.preview-link {
  color: inherit;
}

.preview-link:hover {
  text-decoration: inherit;
}

.article-meta {
  display: block;
  position: relative;
  font-weight: 300;
}

.article-meta img {
  display: inline-block;
  vertical-align: middle;
  height: 32px;
  width: 32px;
  border-radius: 30px;
}

.article-meta .btn + .btn,
.article-meta app-follow-button + app-favorite-button,
.article-meta app-favorite-button {
  margin-left: 0.5rem;
}

.article-meta .info {
  margin: 0 1.5rem 0 0.5rem;
  display: inline-block;
  vertical-align: middle;
  line-height: 1rem;
}

.article-meta .info .author {
  display: block;
  font-weight: 500;
}

.article-meta .info .date {
  color: var(--text-light);
  font-size: 0.8rem;
  display: block;
}

.article-preview {
  border-top: 1px solid #eee;
  padding: 1.5rem 0;
  transition: background-color 0.2s ease;
}

.article-preview:hover {
  background-color: rgba(0, 0, 0, 0.01);
}

.article-preview .article-meta {
  margin: 0 0 1rem 0;
}

.article-preview .preview-link h1 {
  font-weight: 600;
  font-size: 1.5rem;
  line-height: 1.1;
  margin-bottom: 3px;
}

.article-preview .preview-link p {
  font-weight: 300;
  font-size: 1rem;
  color: var(--text-muted);
  margin-bottom: 15px;
  line-height: 1.3rem;
}

.article-preview .preview-link span {
  max-width: 30%;
  font-size: 0.8rem;
  font-weight: 300;
  color: var(--text-light);
  vertical-align: middle;
}

.article-preview .preview-link ul {
  float: right;
  max-width: 50%;
  vertical-align: top;
}

.article-preview .preview-link ul li {
  font-weight: 300;
  font-size: 0.8rem;
  padding-top: 0;
  padding-bottom: 0;
}

.btn .counter {
  font-size: 0.8rem;
}

/* ============================================================================
 * HOME PAGE
 * ============================================================================ */

.home-page .banner p {
  text-align: center;
  font-size: 1rem;
  font-weight: 300;
  color: var(--text-muted);
  margin-bottom: 0;
}

.home-page .banner p a {
  color: var(--brand);
  text-decoration: underline;
}

.home-page .banner h1 {
  font-weight: 700;
  text-align: center;
  font-size: 3.5rem;
  line-height: 1.1;
  padding-bottom: 0.5rem;
}

.home-page .feed-toggle {
  margin-bottom: -1px;
}

.home-page .sidebar {
  margin-left: 10px;
  padding-left: 20px;
  background: none;
  border-left: 1px solid #eee;
}

.home-page .sidebar p {
  margin-bottom: 0.2rem;
}

/* ============================================================================
 * ARTICLE PAGE
 * ============================================================================ */

.article-page .banner {
  padding: 2rem 0;
}

.article-page .banner h1 {
  font-size: 2.8rem;
  font-weight: 600;
  color: #222;
}

.article-page .banner .btn {
  opacity: 0.8;
}

.article-page .banner .btn:hover {
  transition: 0.1s all;
  opacity: 1;
}

.article-page .banner .article-meta {
  margin: 2rem 0 0 0;
}

.article-page .banner .article-meta .author {
  color: var(--text);
}

.article-page .banner > .container,
.article-page .article-content,
.article-page .article-actions,
.article-page .container.page > .row {
  max-width: var(--content-width);
  margin-left: auto;
  margin-right: auto;
}

.article-page .article-content.row,
.article-page .container.page > .row {
  margin-left: auto;
  margin-right: auto;
}

.article-page .article-content .col-md-12,
.article-page .container.page > .row > [class*='col-'] {
  padding-left: 0;
  padding-right: 0;
  flex: 0 0 100%;
  max-width: 100%;
  margin-left: 0;
}

footer > .container {
  display: flex;
  align-items: center;
  max-width: var(--content-width);
  margin-left: auto;
  margin-right: auto;
}

.article-page .article-content p {
  font-family: 'Lora', serif;
  font-size: 1.2rem;
  line-height: 1.8rem;
  margin-bottom: 2rem;
}

.article-page .article-content h1,
.article-page .article-content h2,
.article-page .article-content h3,
.article-page .article-content h4,
.article-page .article-content h5,
.article-page .article-content h6 {
  font-weight: 500;
  margin: 1.6rem 0 1rem 0;
}

.article-page .article-content ul,
.article-page .article-content ol {
  margin-bottom: 1rem;
  padding-left: 2.5rem;
}

.article-page .article-content ul ul,
.article-page .article-content ul ol,
.article-page .article-content ol ul,
.article-page .article-content ol ol {
  margin-bottom: 0;
  padding-left: 2.5rem;
}

.article-page .article-content ul {
  list-style: disc;
}

.article-page .article-content ul ul {
  list-style: circle;
}

.article-page .article-content ul ul ul {
  list-style: square;
}

.article-page .article-content ol {
  list-style: decimal;
}

.article-page .article-content ol ol {
  list-style: lower-alpha;
}

.article-page .article-content ol ol ol {
  list-style: lower-roman;
}

.article-page .article-actions {
  text-align: center;
  margin-top: 1.5rem;
  margin-bottom: 3rem;
}

.article-page .article-actions .article-meta .info {
  text-align: left;
}

.article-page .comment-form .card-block {
  padding: 0;
}

.article-page .comment-form .card-block textarea {
  border: 0;
  padding: 1.25rem;
}

.article-page .comment-form .card-footer .btn {
  font-weight: 700;
  float: right;
}

.article-page .comment-form .card-footer .comment-author-img {
  height: 30px;
  width: 30px;
}

.article-page .card {
  border: 1px solid #eee;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}

.article-page .card .card-footer {
  border-top: 1px solid #eee;
  font-size: 0.8rem;
  font-weight: 300;
}

.article-page .card .comment-author-img {
  display: inline-block;
  vertical-align: middle;
  height: 20px;
  width: 20px;
  border-radius: 30px;
}

.article-page .card .comment-author {
  display: inline-block;
  vertical-align: middle;
}

.article-page .card .date-posted {
  display: inline-block;
  vertical-align: middle;
  margin-left: 5px;
  color: var(--text-light);
}

.article-page .card .mod-options {
  float: right;
  color: #333;
  font-size: 1rem;
}

.article-page .card .mod-options i {
  margin-left: 5px;
  opacity: 0.6;
  cursor: pointer;
}

.article-page .card .mod-options i:hover {
  opacity: 1;
}

/* ============================================================================
 * PROFILE PAGE
 * ============================================================================ */

.profile-page .user-info {
  text-align: center;
  background: #fff;
  border-top: 1px solid #eee;
  border-bottom: 1px solid #eee;
  padding: 2rem 0 1rem 0;
}

.profile-page .user-info .user-img {
  width: 100px;
  height: 100px;
  border-radius: 100px;
  margin-bottom: 1rem;
}

.profile-page .user-info h4 {
  font-weight: 700;
}

.profile-page .user-info p {
  margin: 0 auto 0.5rem auto;
  color: var(--text-lighter);
  max-width: 450px;
  font-weight: 300;
}

.profile-page .user-info .action-btn {
  float: right;
  color: var(--text-muted);
  border: 1px solid var(--text-muted);
}

.profile-page .articles-toggle {
  margin: 1.5rem 0 -1px 0;
}

/* ============================================================================
 * EDITOR PAGE
 * ============================================================================ */

.editor-page .tag-list i {
  font-size: 0.6rem;
  margin-right: 5px;
  cursor: pointer;
}


================================================
FILE: docs/.gitignore
================================================
# build output
dist/
# generated types
.astro/

# dependencies
node_modules/

# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*


# environment variables
.env
.env.production

# macOS-specific files
.DS_Store


================================================
FILE: docs/.vscode/extensions.json
================================================
{
  "recommendations": ["astro-build.astro-vscode"],
  "unwantedRecommendations": []
}


================================================
FILE: docs/.vscode/launch.json
================================================
{
  "version": "0.2.0",
  "configurations": [
    {
      "command": "./node_modules/.bin/astro dev",
      "name": "Development server",
      "request": "launch",
      "type": "node-terminal"
    }
  ]
}


================================================
FILE: docs/README.md
================================================
# Starlight Starter Kit: Basics

[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)

```
npm create astro@latest -- --template starlight
```

[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs)

> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!

## 🚀 Project Structure

Inside of your Astro + Starlight project, you'll see the following folders and files:

```
.
├── public/
├── src/
│   ├── assets/
│   ├── content/
│   │   ├── docs/
│   │   └── config.ts
│   └── env.d.ts
├── astro.config.mjs
├── package.json
└── tsconfig.json
```

Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.

Images can be added to `src/assets/` and embedded in Markdown with a relative link.

Static assets, like favicons, can be placed in the `public/` directory.

## 🧞 Commands

All commands are run from the root of the project, from a terminal:

| Command                   | Action                                           |
| :------------------------ | :----------------------------------------------- |
| `npm install`             | Installs dependencies                            |
| `npm run dev`             | Starts local dev server at `localhost:4321`      |
| `npm run build`           | Build your production site to `./dist/`          |
| `npm run preview`         | Preview your build locally, before deploying     |
| `npm run astro ...`       | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI                     |

## 👀 Want to learn more?

Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).


================================================
FILE: docs/astro.config.mjs
================================================
import {defineConfig} from 'astro/config';
import starlight from '@astrojs/starlight';
import tailwindcss from "@tailwindcss/vite";

/**
 * A Vite plugin that removes `.md` extensions from URLs during the build process.
 *
 * This plugin is useful when working with Markdown files in a static site generator
 * like Astro or Vite. It allows URLs to be served without the `.md` extension in
 * the final build, making the URLs cleaner (e.g., `/docs/page` instead of `/docs/page.md`).
 *
 * ## Example Usage
 * ```js
 * import { defineConfig } from 'astro/config';
 *
 * export default defineConfig({
 *   vite: {
 *     plugins: [removeMdExtension()],
 *   },
 * });
 * ```
 *
 * @typedef {object} VitePlugin
 * @property {string} name - The name of the plugin, in this case, `remove-md-extension`.
 * @property {string} enforce - Specifies the plugin's enforcement stage, set to `pre`
 * to ensure that this plugin runs before other transformations during the build.
 * @property {function} transform - The function that processes each file, removing
 * `.md` extensions from the file content. It is called on every file during the build process.
 *
 * @returns {VitePlugin} A Vite-compatible plugin object that contains the `name`,
 * `enforce`, and `transform` properties, implementing the plugin functionality.
 *
 * ## Vite Plugin Object Structure
 * - `name`: The name of the plugin (`remove-md-extension`).
 * - `enforce`: Ensures the plugin runs early (`pre` stage).
 * - `transform(code: string, id: string): string`: The function that processes the content of `.md` files.
 *
 * @param {string} code - The content of the file being processed (e.g., the raw Markdown).
 * @param {string} id - The file identifier (usually the file path), used to check if the file ends in `.md`.
 * @returns {string} The modified file content, with any `.md` extensions in URLs removed.
 */
function removeMdExtension() {
    return {
        name: 'remove-md-extension',
        enforce: 'pre',
        transform(code, id) {
            if (id.endsWith('.md')) {
                return code.replace(/\.md/g, '');
            }
            return code;
        },
    };
};

// https://astro.build/config
export default defineConfig({
    integrations: [starlight({
        title: 'RealWorld',
        social: [
            {
                icon: 'github',
                label: 'GitHub',
                href: 'https://github.com/realworld-apps/realworld'
            }
        ],
        customCss: [
            './src/tailwind.css',
        ],
        sidebar: [
            {
                label: 'Implementation creation',
                items: [
                    {
                        label: 'Introduction',
                        slug: 'implementation-creation/introduction',
                    },
                    {
                        label: 'Features',
                        slug: 'implementation-creation/features',
                    },
                    {
                        label: 'Expectations',
                        slug: 'implementation-creation/expectations',
                    }

                ]
            },
            {
                label: 'Specifications',
                items: [
                    {
                        label: 'Frontend specifications',
                        items: [
                            {
                                label: 'Templates',
                                slug: 'specifications/frontend/templates',
                            },
                            {
                              label: 'Styles',
                                slug: 'specifications/frontend/styles',
                            },
                            {
                                label: 'Routing',
                                slug: 'specifications/frontend/routing',
                            },
                            {
                                label: 'API',
                                slug: 'specifications/frontend/api',
                            },
                            {
                                label: 'Tests',
                                slug: 'specifications/frontend/tests',
                            }
                        ]
                    },
                    {
                        label: 'Backend specifications',
                        items: [
                            {
                                label: 'Introduction',
                                slug: 'specifications/backend/introduction',
                            },
                            {
                                label: 'Endpoints',
                                slug: 'specifications/backend/endpoints',
                            },
                            {
                                label: 'API response format',
                                slug: 'specifications/backend/api-response-format',
                            },
                            {
                                label: 'CORS',
                                slug: 'specifications/backend/cors',
                            },
                            {
                                label: 'Error handling',
                                slug: 'specifications/backend/error-handling',
                            },
                            {
                                label: 'Hurl',
                                slug: 'specifications/backend/hurl',
                            },
                            {
                                label: 'Tests',
                                slug: 'specifications/backend/tests',
                            }
                        ]
                    },
                    {
                        label: 'Mobile specifications',
                        slug: 'specifications/mobile-specs/introduction'
                    }
                ]
            },
            {
                label: 'Community',
                items: [
                    // Each item here is one entry in the navigation menu.
                    {
                        label: 'Authors',
                        slug: 'community/authors',
                    },
                    {
                        label: 'Resources',
                        slug: 'community/resources',
                    },
                    {
                        label: 'Special Thanks',
                        slug: 'community/special-thanks',
                    }
                ]
            }
        ]
    })],
    vite: {
        plugins: [tailwindcss(), removeMdExtension()],
        ssr: {
            noExternal: ['@astrojs/starlight-tailwind'],
        },
    }
});


================================================
FILE: docs/non-included/LICENSES_LOGOS.md
================================================
# Logo Licenses & Trademark Analysis

> **Disclaimer**: This document was generated by Claude (Anthropic) and is provided
> for informational purposes only. It does not constitute legal advice. Consult a
> qualified attorney for legal guidance.

This document covers the licensing and trademark status of the framework logos
used in the animated SVG (`frameworks.svg`) of this repository.

## Individual Logo Licenses

### Angular
- **License**: CC BY 4.0 (Creative Commons Attribution 4.0 International)
- **Source**: [angular.dev/press-kit](https://angular.dev/press-kit)
- **Requirements**: Attribution to Google required
- **Trademark**: "ANGULAR" is a registered trademark of Google LLC

### React
- **License**: CC BY 4.0 (Creative Commons Attribution 4.0 International)
- **Source**: [github.com/facebook/react](https://github.com/facebook/react) / react.dev repo
- **Requirements**: Attribution to Meta required
- **Trademark**: React name and logo are trademarks of Meta Platforms, Inc.
- **Reference**: Confirmed in [GitHub issue #12570](https://github.com/facebook/react/issues/12570)

### Vue.js
- **License**: CC BY-NC-SA 4.0 (Creative Commons Attribution-NonCommercial-ShareAlike 4.0)
- **Source**: [github.com/vuejs/art](https://github.com/vuejs/art)
- **Requirements**: Attribution, non-commercial use, share-alike on derivatives
- **Permitted explicitly**: Open source / non-profit projects related to Vue.js
- **Trademark**: VUE.JS is a registered US trademark owned by Yuxi (Evan) You

### Django
- **License**: No open license — governed by DSF Trademark Policy
- **Source**: [djangoproject.com/community/logos](https://www.djangoproject.com/community/logos/)
- **Requirements**:
  - Must not imply endorsement by the Django Software Foundation
  - Must not modify the logo (colors, proportions, added text)
- **Trademark**: Registered trademark of the Django Software Foundation
- **Reference**: [djangoproject.com/trademarks](https://www.djangoproject.com/trademarks/)

### Hono
- **License**: MIT (as part of the honojs/hono repository)
- **Source**: [github.com/honojs/hono/docs/images](https://github.com/honojs/hono/tree/main/docs/images)
- **Requirements**: Include MIT copyright notice
- **Also available as**: CC0 1.0 (public domain) on third-party collections (gilbarbara/logos, Wikimedia Commons)

## Combined License of the Animated SVG

Since `frameworks.svg` is a derivative work incorporating all five logos, the
**most restrictive compatible terms** apply to the combined work.

### Governing constraints

| Constraint        | Source         | Impact                                         |
|-------------------|----------------|-------------------------------------------------|
| **Attribution**   | Angular, React, Vue, Hono | Credit all projects and their respective owners |
| **NonCommercial** | Vue.js (CC BY-NC-SA 4.0)  | The animated SVG cannot be used commercially    |
| **ShareAlike**    | Vue.js (CC BY-NC-SA 4.0)  | Derivatives of the SVG must use CC BY-NC-SA 4.0 or compatible |
| **No modification** | Django (trademark) | The Django logo within the SVG must remain unmodified (colors, proportions) |
| **Linking**       | Django (trademark) | Logo should link to djangoproject.com "wherever technically possible" — not applicable here as the logo cycles within an animated SVG, making a dedicated hyperlink technically infeasible |
| **No endorsement** | All (trademark) | Must not imply official affiliation with any project |

### Effective license for this animated SVG

**CC BY-NC-SA 4.0**, with additional trademark constraints from Django.

This means:
- Free to share and adapt for **non-commercial** purposes
- Must give **attribution** to all five projects
- Derivatives must be shared under **the same license**
- The Django logo portion must remain **unmodified** and **link to djangoproject.com** when possible
- Usage must not suggest **endorsement** by any of the projects

### Risk assessment

| Logo    | Risk  | Notes                                                          |
|---------|-------|----------------------------------------------------------------|
| Angular | Low   | CC BY 4.0 is very permissive                                  |
| React   | Low   | CC BY 4.0 is very permissive                                  |
| Vue.js  | Low   | Explicitly allows open-source project use                     |
| Django  | Medium | Trademark-only; animation of the logo could be seen as modification. We use minimal fade-in/out animation only, keeping the logo visually unaltered |
| Hono    | Low   | MIT / CC0 — most permissive                                   |

### Recommendation

The animated SVG is suitable for an open-source demo-apps repository, provided:
1. This `LICENSES_LOGOS.md` file is kept in the repository for attribution
2. The Django logo is not distorted, recolored, or visually altered (fade-in/out is acceptable)
3. The Django logo links to djangoproject.com when technically possible
4. The repository does not imply official endorsement by any of the five projects

## Attribution

- Angular logo by Google — [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- React logo by Meta Platforms, Inc. — [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- Vue.js logo by Evan You — [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
- Django logo by the Django Software Foundation — [Trademark Policy](https://www.djangoproject.com/trademarks/)
- Hono logo by Yusuke Wada — [MIT License](https://github.com/honojs/hono/blob/main/LICENSE)

---

*Generated by Claude (Anthropic)*


================================================
FILE: docs/package.json
================================================
{
  "name": "documentation",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "dev": "astro dev",
    "start": "astro dev",
    "build": "astro check && astro build",
    "preview": "astro preview",
    "astro": "astro"
  },
  "dependencies": {
    "@astrojs/check": "^0.9.6",
    "@astrojs/react": "^4.4.2",
    "@astrojs/starlight": "^0.37.4",
    "@astrojs/starlight-tailwind": "^4.0.2",
    "@tailwindcss/vite": "^4.1.8",
    "@types/react": "^19.2.10",
    "@types/react-dom": "^19.2.3",
    "astro": "^5.16.16",
    "react": "^19.2.4",
    "react-dom": "^19.2.4",
    "sharp": "^0.34.5",
    "tailwindcss": "^4.1.18",
    "typescript": "^5.9.3"
  }
}


================================================
FILE: docs/src/content/config.ts
================================================
import { defineCollection } from 'astro:content';
import { docsSchema } from '@astrojs/starlight/schema';

export const collections = {
	docs: defineCollection({ schema: docsSchema() }),
};


================================================
FILE: docs/src/content/docs/community/authors.md
================================================
---
title: Authors
---

# Who currently maintains the project

#### [Gérôme Grignon](https://github.com/geromegrignon) - Maintainer

<img class="mr-4" align="left" width="40" height="40" src="https://avatars.githubusercontent.com/u/32737308?v=4">

Gérôme is a Software Engineer at Lucca. He's an open-source enthusiast.<br /><br />

#### [Manuel Vila](https://github.com/mvila) - Maintainer

<img class="mr-4" align="left" width="40" height="40" src="https://avatars.githubusercontent.com/u/381671?v=40">

Manuel is an independent Software Engineer, creator of the [Layr framework](https://layrjs.com) and the [CodebaseShow website](https://codebase.show/).<br /><br />



# Who created it

RealWorld would not be possible without the [open source community](https://realworld-docs.netlify.app/docs/community/special-thanks) continuously helping push the project forward. In addition, the former team was composed of:

#### [Anish Karandikar](https://github.com/anishkny) - Core Maintainer

<img class="mr-4" align="left" width="40" height="40" src="https://avatars1.githubusercontent.com/u/357499?v=3&s=100" />

MathWorker, ex-Google, ex-Computational Fluid Dynamicist, forever lover of tech & humanities ❤️

#### [Cameron Chapman](https://github.com/Cameron-C-Chapman) - Core Maintainer

<img class="mr-4" align="left" width="40" height="40" src="https://avatars1.githubusercontent.com/u/1323581?v=3&s=100" />

Cameron Chapman is a Software Engineer at FanThreeSixty. He's an open source enthusiast and is helping to teach a local web development boot camp at Kansas University.

#### [Eric Simons](https://twitter.com/ericsimons40) - Founder/Maintainer

<img class="mr-4" align="left" width="40" height="40" src="https://avatars1.githubusercontent.com/u/556934?v=3&s=100" />

Eric is a Software Engineer, UI Designer, and author of many technical books & tutorials. He oversees the project direction, maintenance and organizes the planning and development efforts of the team.

#### [Albert Pai](https://twitter.com/iamalbertpai) - Founder/Maintainer

<img class="mr-4" align="left" width="40" height="40" src="https://avatars0.githubusercontent.com/u/1776432?v=3&s=100" />

Albert is a Software Engineer, DevOps ninja, and author of many technical books & tutorials. He oversees the project direction, maintenance and organizes the planning and development efforts of the team.

#### [Thinkster](https://twitter.com/gothinkster) - Funded

<img class="mr-4" align="left" width="40" height="40" src="https://avatars0.githubusercontent.com/u/8601733?v=3&s=100" />

[Thinkster](https://x.com/GoThinkster) created high quality resources that helped Javascript developers succeed. The RealWorld project wouldn't exist without their funding!

#### [James Brewer](https://twitter.com/brwr_) - Admin

<img class="mr-4" align="left" width="40" height="40" src="https://avatars1.githubusercontent.com/u/4095660?v=3&s=100" />

James is a Software Engineer at Square and a contributor to the Django project. He created & maintained the RW Django codebase and continually provides guidance for the RealWorld project itself.

#### [Sandeesh S.](https://github.com/sandeesh) - Admin

<img class="mr-4" align="left" width="40" height="40" src="https://avatars1.githubusercontent.com/u/16877877?v=3&s=100" />

Full stack developer, Laravel enthusiast, Digital marketing specialist and an avid gamer.


================================================
FILE: docs/src/content/docs/community/resources.md
================================================
---
title: Resources
---

# Community created resources

- Performance comparisons:
  - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2020](https://medium.com/dailyjs/a-realworld-comparison-of-front-end-frameworks-2020-4e50655fe4c1)
  - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2019](https://medium.freecodecamp.org/a-realworld-comparison-of-front-end-frameworks-with-benchmarks-2019-update-4be0d3c78075)
  - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2018](https://medium.freecodecamp.org/a-real-world-comparison-of-front-end-frameworks-with-benchmarks-2018-update-e5760fb4a962)
  - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2017](https://medium.freecodecamp.org/a-real-world-comparison-of-front-end-frameworks-with-benchmarks-e1cb62fd526c)

:::tip
Hello fellow writer, get in touch with us in [**GitHub Discussions**](https://github.com/realworld-apps/realworld/discussions/categories/community) so we can add your RealWorld related content here.
:::


================================================
FILE: docs/src/content/docs/community/special-thanks.md
================================================
---
title: Special thanks
---

RealWorld would not be possible without the open source community's assistance in reviewing codebases, developing new app implementations, and a variety of other duties that help the project progress. We'd like to thank the following OSS leaders for their contributions to RealWorld:

- **Dan Abramov** (creator of Redux) for helping [spark the initial idea](https://twitter.com/dan_abramov/status/692009757775896577), [getting the Redux community involved](https://github.com/reactjs/redux/issues/1353), as well as graciously taking the time to provide feedback on the Redux codebase
- **Max Lynch** (creator of Ionic) for taking the time to provide guidance in the early days of this project
- **Addy Osmani** (creator of TodoMVC) for helping [spark the initial idea](https://twitter.com/addyosmani/status/762828483433144320) and his amazing work with TodoMVC
- **TodoMVC** ([team & contributors](https://github.com/tastejs/todomvc#team)) for their exemplary & successful work; their project & org has been an invaluable analogy for us as we've built out RealWorld
- **James Brewer** (docs contributor to Django) for countless brainstorming sessions, helping name this project, and creating the Django codebase + tutorial


================================================
FILE: docs/src/content/docs/implementation-creation/expectations.md
================================================
---
title: Expectations
---

## Remember: Keep your codebases _simple_, yet _robust_.

If a new developer to your framework comes along and takes longer than 10 minutes to grasp the high-level architecture, it's likely that you went a little overboard in the engineering department.

Alternatively, you should _never_ forgo following fundamental best practices for the sake of simplicity, lest we teach that same newbie dev the _wrong_ way of doing things.

The quality & architecture of Conduit implementations should reflect something similar to an early-stage startup's MVP: functionally complete & stable, but not unnecessarily over-engineered.

## To write tests, or to not write tests?

**TL;DR** — we require a minimum of **one** unit test with every repo, but we'd definitely prefer all of them to include excellent testing coverage if the maintainers are willing to add it (or if someone in the community is kind enough to make a pull request :)

We believe that tests are a good concept, and we are big supporters of TDD in general. Building Conduit implementations without complete testing coverage, on the other hand, is a significant time commitment in and of itself, therefore we didn't include it in the spec at first since we believed that if people wanted it, it would be a fantastic "extra credit" aim for the repo. For example, a request for unit tests was made in our Angular 2 repo, and several fantastic community members are presently working on a PR to address it.

Another reason we didn’t include them in the spec is from the "Golden Rule" above:

> The quality & architecture of Conduit implementations should reflect something similar to an early-stage startup's MVP: functionally complete & stable, but not unnecessarily over-engineered.

Most startups we know that work in consumer-facing apps (like Conduit) don’t apply TDD/testing until they have a solid product-market fit, which is smart because they then spend most of their time iterating on product & UI and thus are far more likely to find PMF.

This doesn’t mean that TDD/testing === over-engineering, but in certain circumstances that statement does evaluate true (ex: consumer product finding PMF, side-projects, robust prototypes, etc).

That said, we do _prefer_ that every repo includes excellent tests that are exemplary of TDD/testing with that framework 👍

## Other Expectations

- All the required features (see specs) should be implemented.
- You should publish your implementation on a dedicated GitHub repository with the "Issues" section open.
- You should provide a README that presents an overview of your implementation and explains how to run it locally.
- The library/framework you are using should have at least 300 GitHub stars.
- You should do your best to keep your implementation up to date.


================================================
FILE: docs/src/content/docs/implementation-creation/features.md
================================================
---
title: Features
---

**General functionality:**

- Authenticate users via JWT (login/signup pages + logout button on settings page)
- CRU- users (sign up & settings page - no deleting required)
- CRUD Articles
- CR-D Comments on articles (no updating required)
- GET and display paginated lists of articles
- Favorite articles
- Follow other users


================================================
FILE: docs/src/content/docs/implementation-creation/introduction.md
================================================
---
title: Introduction
---

**Conduit** is a social blogging site (i.e. a Medium.com clone). It uses a custom API for all requests, including authentication.

:::tip
Check for [Discussions](https://github.com/realworld-apps/realworld/discussions/categories/wip-implementations) about works in progress as we don't list duplicate projects.  
An opportunity to collaborate might await you already.
:::

Otherwise:

1. [fork our starter kit](https://github.com/gothinkster/realworld-starter-kit)
2. Read the following sections: _expectations_ and _features_ for a better understanding of this project
3. Read the frontend and/or the backend specs
4. Submit the new implementation on [CodebaseShow](https://codebase.show/projects/realworld)

**Happy coding!**


================================================
FILE: docs/src/content/docs/index.mdx
================================================
---
title: RealWorld apps
description: It's all about building real world, production ready apps.
template: splash
hero:
  tagline: |
    While most "todo" demos provide an excellent cursory glance at a framework's capabilities, they typically don't convey the knowledge & perspective required to actually build real applications with it. That's why we, with the help of open source experts, design and serve as exemplary real world applications for each framework.
  image:
    file: ../../assets/img/realworld-logo.png
  actions:
    - text: Documentation
      link: /introduction
      icon: right-arrow
---


================================================
FILE: docs/src/content/docs/introduction.mdx
================================================
---
title: Introduction
---

# Introduction

> See how _the exact same_ Medium.com clone is built using different [frontends](https://codebase.show/projects/realworld?category=frontend) and [backends](https://codebase.show/projects/realworld?category=backend). Yes, you can mix and match them, because **they all adhere to the same [API spec](/specifications/backend/introduction)** 😮😎

While most "todo" demos provide an excellent cursory glance at a framework's capabilities, they typically don't convey the knowledge & perspective required to actually build _real_ applications with it.

**RealWorld** solves this by allowing you to choose any frontend (React, Angular, & more) and any backend (Node, Django, & more).

_Read the [full blog post announcing RealWorld on Medium.](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5)_

Join us on [GitHub Discussions!](https://github.com/realworld-apps/realworld/discussions) 🎉

## Implementations

Over 150 implementations have been created using various languages, libraries, and frameworks.

Explore them on [**CodebaseShow**](https://codebase.show/projects/realworld).

## Create a new implementation

[**Create a new implementation >>>**](implementation-creation/introduction)

Or you can [view upcoming implementations (WIPs)](https://github.com/realworld-apps/realworld/discussions/categories/wip-implementations).

## Learn more

- ["Introducing RealWorld 🙌"](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5) by Eric Simons
- Every tutorial is built against the same [API spec](/specifications/backend/introduction) to ensure modularity of every frontend & backend
- Every frontend utilizes the same hand crafted [Bootstrap 4 theme](https://github.com/gothinkster/conduit-bootstrap-template) for identical UI/UX
- There is a [hosted version](https://realworld-docs.netlify.app/docs/specs/frontend-specs/api#demo-api) of the backend API available for public usage, no API keys are required
- Interested in creating a new RealWorld stack? View our [starter guide & spec](/implementation-creation/introduction)


================================================
FILE: docs/src/content/docs/specifications/backend/api-response-format.md
================================================
---
title: API response format
---

## JSON Objects returned by API:

Make sure the right content type like `Content-Type: application/json; charset=utf-8` is correctly returned.

### Users (for authentication)

```json
{
  "user": {
    "email": "jake@jake.jake",
    "token": "jwt.token.here",
    "username": "jake",
    "bio": null,
    "image": null
  }
}
```

### Profile

```json
{
  "profile": {
    "username": "jake",
    "bio": "I work at statefarm",
    "image": "https://api.realworld.io/images/smiley-cyrus.jpg",
    "following": false
  }
}
```

### Single Article

```json
{
  "article": {
    "slug": "how-to-train-your-dragon",
    "title": "How to train your dragon",
    "description": "Ever wonder how?",
    "body": "It takes a Jacobian",
    "tagList": ["dragons", "training"],
    "createdAt": "2016-02-18T03:22:56.637Z",
    "updatedAt": "2016-02-18T03:48:35.824Z",
    "favorited": false,
    "favoritesCount": 0,
    "author": {
      "username": "jake",
      "bio": "I work at statefarm",
      "image": "https://i.stack.imgur.com/xHWG8.jpg",
      "following": false
    }
  }
}
```

### Multiple Articles

:::caution
Starting from the 2024/08/16, the endpoints retrieving a list of articles do no longer return the body of an article for performance reasons.
It affcts: 
- `GET /api/articles`
- `GET /api/articles/feed`
:::

```json
{
  "articles":[{
    "slug": "how-to-train-your-dragon",
    "title": "How to train your dragon",
    "description": "Ever wonder how?",
    "tagList": ["dragons", "training"],
    "createdAt": "2016-02-18T03:22:56.637Z",
    "updatedAt": "2016-02-18T03:48:35.824Z",
    "favorited": false,
    "favoritesCount": 0,
    "author": {
      "username": "jake",
      "bio": "I work at statefarm",
      "image": "https://i.stack.imgur.com/xHWG8.jpg",
      "following": false
    }
  }, {
    "slug": "how-to-train-your-dragon-2",
    "title": "How to train your dragon 2",
    "description": "So toothless",
    "tagList": ["dragons", "training"],
    "createdAt": "2016-02-18T03:22:56.637Z",
    "updatedAt": "2016-02-18T03:48:35.824Z",
    "favorited": false,
    "favoritesCount": 0,
    "author": {
      "username": "jake",
      "bio": "I work at statefarm",
      "image": "https://i.stack.imgur.com/xHWG8.jpg",
      "following": false
    }
  }],
  "articlesCount": 2
}
```

### Single Comment

```json
{
  "comment": {
    "id": 1,
    "createdAt": "2016-02-18T03:22:56.637Z",
    "updatedAt": "2016-02-18T03:22:56.637Z",
    "body": "It takes a Jacobian",
    "author": {
      "username": "jake",
      "bio": "I work at statefarm",
      "image": "https://i.stack.imgur.com/xHWG8.jpg",
      "following": false
    }
  }
}
```

### Multiple Comments

```json
{
  "comments": [{
    "id": 1,
    "createdAt": "2016-02-18T03:22:56.637Z",
    "updatedAt": "2016-02-18T03:22:56.637Z",
    "body": "It takes a Jacobian",
    "author": {
      "username": "jake",
      "bio": "I work at statefarm",
      "image": "https://i.stack.imgur.com/xHWG8.jpg",
      "following": false
    }
  }]
}
```

### List of Tags

```json
{
  "tags": [
    "reactjs",
    "angularjs"
  ]
}
```


================================================
FILE: docs/src/content/docs/specifications/backend/bruno.md
================================================
---
title: Bruno
---

For your convenience, we have a [Bruno collection](https://github.com/realworld-apps/realworld/tree/main/specs/api/bruno) that you can use to test your API endpoints as you build your app. You can run them all with [`run-api-tests-bruno.sh`](https://github.com/realworld-apps/realworld/blob/main/specs/api/run-api-tests-bruno.sh), or open the `bruno/` folder in the [Bruno app](https://www.usebruno.com) to run requests interactively.

The Bruno collection is automatically generated from the [Hurl test suite](/specifications/backend/hurl), which is the source of truth. It is kept in sync via CI.

## Running API tests locally

To locally run the provided Bruno collection against your backend, follow instructions [here](https://github.com/realworld-apps/realworld/tree/main/specs/api).


================================================
FILE: docs/src/content/docs/specifications/backend/cors.md
================================================
---
title: CORS
---

## Considerations for your backend with [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing)

If the backend is about to run on a different host/port than the frontend, make sure to handle `OPTIONS` too and return correct `Access-Control-Allow-Origin` and `Access-Control-Allow-Headers` (e.g. `Content-Type`).


================================================
FILE: docs/src/content/docs/specifications/backend/endpoints.md
================================================
---
title: Endpoints
---

### Authentication Header:

You can read the authentication header from the headers of the request

`Authorization: Token jwt.token.here`

### Authentication:

`POST /api/users/login`

Example request body:

```json
{
  "user":{
    "email": "jake@jake.jake",
    "password": "jakejake"
  }
}
```

No authentication required, returns a [User](/specifications/backend/api-response-format#users-for-authentication)

Required fields: `email`, `password`

### Registration:

`POST /api/users`

Example request body:

```json
{
  "user":{
    "username": "Jacob",
    "email": "jake@jake.jake",
    "password": "jakejake"
  }
}
```

No authentication required, returns a [User](/specifications/backend/api-response-format#users-for-authentication)

Required fields: `email`, `username`, `password`

### Get Current User

`GET /api/user`

Authentication required, returns a [User](/specifications/backend/api-response-format#users-for-authentication) that's the current user

### Update User

`PUT /api/user`

Example request body:

```json
{
  "user":{
    "email": "jake@jake.jake",
    "bio": "I like to skateboard",
    "image": "https://i.stack.imgur.com/xHWG8.jpg"
  }
}
```

Authentication required, returns the [User](/specifications/backend/api-response-format#users-for-authentication)

Accepted fields: `email`, `username`, `password`, `image`, `bio`

### Get Profile

`GET /api/profiles/:username`

Authentication optional, returns a [Profile](/specifications/backend/api-response-format#profile)

### Follow user

`POST /api/profiles/:username/follow`

Authentication required, returns a [Profile](/specifications/backend/api-response-format#profile)

No additional parameters required

### Unfollow user

`DELETE /api/profiles/:username/follow`

Authentication required, returns a [Profile](/specifications/backend/api-response-format#profile)

No additional parameters required

### List Articles

`GET /api/articles`

Returns most recent articles globally by default, provide `tag`, `author` or `favorited` query parameter to filter results

Query Parameters:

Filter by tag:

`?tag=AngularJS`

Filter by author:

`?author=jake`

Favorited by user:

`?favorited=jake`

Limit number of articles (default is 20):

`?limit=20`

Offset/skip number of articles (default is 0):

`?offset=0`

Authentication optional, will return [multiple articles](/specifications/backend/api-response-format#multiple-articles), ordered by most recent first

### Feed Articles

`GET /api/articles/feed`

Can also take `limit` and `offset` query parameters like [List Articles](/specifications/backend/api-response-format#list-articles)

Authentication required, will return [multiple articles](/specifications/backend/api-response-format#multiple-articles) created by followed users, ordered by most recent first.

### Get Article

`GET /api/articles/:slug`

No authentication required, will return [single article](/specifications/backend/api-response-format#single-article)

### Create Article

`POST /api/articles`

Example request body:

```json
{
  "article": {
    "title": "How to train your dragon",
    "description": "Ever wonder how?",
    "body": "You have to believe",
    "tagList": ["reactjs", "angularjs", "dragons"]
  }
}
```

Authentication required, will return an [Article](/specifications/backend/api-response-format#single-article)

Required fields: `title`, `description`, `body`

Optional fields: `tagList` as an array of Strings

### Update Article

`PUT /api/articles/:slug`

Example request body:

```json
{
  "article": {
    "title": "Did you train your dragon?"
  }
}
```

Authentication required, returns the updated [Article](/specifications/backend/api-response-format#single-article)

Optional fields: `title`, `description`, `body`

The `slug` also gets updated when the `title` is changed

### Delete Article

`DELETE /api/articles/:slug`

Authentication required

### Add Comments to an Article

`POST /api/articles/:slug/comments`

Example request body:

```json
{
  "comment": {
    "body": "His name was my name too."
  }
}
```

Authentication required, returns the created [Comment](/specifications/backend/api-response-format#single-comment)

Required field: `body`

### Get Comments from an Article

`GET /api/articles/:slug/comments`

Authentication optional, returns [multiple comments](/specifications/backend/api-response-format#multiple-comments)

### Delete Comment

`DELETE /api/articles/:slug/comments/:id`

Authentication required

### Favorite Article

`POST /api/articles/:slug/favorite`

Authentication required, returns the [Article](/specifications/backend/api-response-format#single-article)

No additional parameters required

### Unfavorite Article

`DELETE /api/articles/:slug/favorite`

Authentication required, returns the [Article](/specifications/backend/api-response-format#single-article)

No additional parameters required

### Get Tags

`GET /api/tags`

No authentication required, returns a [List of Tags](/specifications/backend/api-response-format#list-of-tags)


================================================
FILE: docs/src/content/docs/specifications/backend/error-handling.md
================================================
---
title: Error handling
---

### Errors and Status Codes

If a request fails any validations, expect a 422 and errors in the following format:

```json
{
  "errors":{
    "body": [
      "can't be empty"
    ]
  }
}
```

#### Other status codes:

401 for Unauthorized requests, when a request requires authentication but it isn't provided

403 for Forbidden requests, when a request may be valid but the user doesn't have permissions to perform the action

404 for Not found requests, when a resource can't be found to fulfill the request


================================================
FILE: docs/src/content/docs/specifications/backend/hurl.md
================================================
---
title: Hurl
---

For your convenience, we have a [Hurl collection](https://github.com/realworld-apps/realworld/tree/main/specs/api/hurl) that you can use to test your API endpoints as you build your app. You can run them all with [`run-api-tests-hurl.sh`](https://github.com/realworld-apps/realworld/blob/main/specs/api/run-api-tests-hurl.sh).

A [Bruno collection](/specifications/backend/bruno) is also available if you prefer a GUI-based workflow.

## Running API tests locally

To locally run the provided Hurl collection against your backend, follow instructions [here](https://github.com/realworld-apps/realworld/tree/main/specs/api).


================================================
FILE: docs/src/content/docs/specifications/backend/introduction.md
================================================
---
title: Introduction
---

All backend implementations need to adhere to our [API spec](https://github.com/realworld-apps/realworld/tree/main/specs/api). The full API is described in the [OpenAPI spec](https://github.com/realworld-apps/realworld/blob/main/specs/api/openapi.yml).

For your convenience, we have a [Hurl collection](https://github.com/realworld-apps/realworld/tree/main/specs/api/hurl) that you can use to test your API endpoints as you build your app. You can run them all with [`run-api-tests-hurl.sh`](https://github.com/realworld-apps/realworld/blob/main/specs/api/run-api-tests-hurl.sh).

Check out our [starter kit](https://github.com/gothinkster/realworld-starter-kit) to create a new implementation, please read [references to the API specs & testing](/specifications/backend/introduction) required for creating a new backend.


================================================
FILE: docs/src/content/docs/specifications/backend/postman.md
================================================
---
title: Postman
---

For your convenience, we have a [Postman collection](https://github.com/realworld-apps/realworld/blob/main/specs/api/legacy_Conduit.postman_collection.json) that you can use to test your API endpoints as you build your app.

## Running API tests locally

To locally run the provided Postman collection against your backend, follow instructions [here](https://github.com/realworld-apps/realworld/tree/main/specs/api).


================================================
FILE: docs/src/content/docs/specifications/backend/tests.md
================================================
---
title: Tests
---

Include _at least_ **one** unit test in your repo to demonstrate how testing works (full testing coverage is _not_ required!)


================================================
FILE: docs/src/content/docs/specifications/frontend/api.md
================================================
---
title: API
---

This project provides you different solutions to test your frontend implementation with an API by:

- [running our official backend implementation locally](#run-the-official-backend-implementation-locally)
- [using the API deployed for the official demo](#demo-api)

## Run the official backend implementation locally

The official backend implementation is open-sourced.  
You can find the GitHub repository [here](https://github.com/realworld-apps/nitro-prisma-zod-realworld-example-app).
The Readme will provide you guidances to start the server locally.

## Demo API

This project provides you with a public hosted API to test your frontend implementations.  
Point your API requests to `https://api.realworld.show/api` and you're good to go!

### API Usage

The API is freely available for public usage but its access is limited to RealWorld usage only: you won't be able to t consume it on its own but with a frontend application.

## API Limitations

:::info
To avoid the need for content moderation on the public API, the following limitations have been introduced in 2021
:::

The visibility of user content is limited :

- logged out users see only content created by demo accounts
- logged in users see only their content and the content created by demo accounts


================================================
FILE: docs/src/content/docs/specifications/frontend/routing.md
================================================
---
title: Routing
---

- Home page (URL: `/` )
  - List of tags
  - List of articles pulled from either Feed, Global, or by Tag
  - Pagination for list of articles
- Sign in/Sign up pages (URL: `/login`, `/register` )
  - Uses JWT (store the token in localStorage)
  - Authentication can be easily switched to session/cookie based
- Settings page (URL: `/settings` )
- Editor page to create/edit articles (URL: `/editor`, `/editor/article-slug-here` )
- Article page (URL: `/article/article-slug-here` )
  - Delete article button (only shown to article's author)
  - Render markdown from server client side
  - Comments section at bottom of page
  - Delete comment button (only shown to comment's author)
- Profile page (URL: `/profile/:username`, `/profile/:username/favorites` )
  - Show basic user info
  - List of articles populated from author's created articles or author's favorited articles


================================================
FILE: docs/src/content/docs/specifications/frontend/styles.md
================================================
---
title: Styles
---

All frontend implementations should use the shared [styles.css](https://github.com/realworld-apps/realworld/blob/main/assets/theme/styles.css) file from the main repository. This is a self-contained CSS file (Conduit Minimal CSS v4) that includes only the classes actually used by Conduit.

The CSS classes it provides match the [templates](/specifications/frontend/templates) and the [E2E test selectors contract](https://github.com/realworld-apps/realworld/blob/main/specs/e2e/SELECTORS.md).

### Default Avatar

When a user has no profile image, implementations should display the [default avatar](https://github.com/realworld-apps/realworld/blob/main/assets/media/default-avatar.svg) (a smiley face icon).


================================================
FILE: docs/src/content/docs/specifications/frontend/templates.md
================================================
---
title: Templates
---

## Head

The `<head>` element includes all the metadata for a page, including the title, description, and links to stylesheets and scripts.

```html
<head>
  <meta charset="utf-8" />
  <title>Conduit</title>
  <!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on -->
  <link
    href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
    rel="stylesheet"
    type="text/css"
  />
  <link
    href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
    rel="stylesheet"
    type="text/css"
  />
  <!-- Import the custom Bootstrap 4 theme from our hosted CDN -->
  <link rel="stylesheet" href="//demo.productionready.io/main.css" />
</head>
```

## Layout

### Header

#### Unauthenticated user

If no user is logged in, then the header should include links to:

- the home page
- the login page
- the register page

> the link of the active page should use the **active** css class.

```html
<nav class="navbar navbar-light">
  <div class="container">
    <a class="navbar-brand" href="/">conduit</a>
    <ul class="nav navbar-nav pull-xs-right">
      <li class="nav-item">
        <!-- Add "active" class when you're on that page" -->
        <a class="nav-link active" href="/">Home</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="/login">Sign in</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="/register">Sign up</a>
      </li>
    </ul>
  </div>
</nav>
```

#### Authenticated user

If a user is logged in, then the header should include links to:

- the home page
- the new article page
- the settings page
- the profile page

> the link of the active page should use the **active** css class.

```html
<nav class="navbar navbar-light">
  <div class="container">
    <a class="navbar-brand" href="/">conduit</a>
    <ul class="nav navbar-nav pull-xs-right">
      <li class="nav-item">
        <!-- Add "active" class when you're on that page" -->
        <a class="nav-link active" href="/">Home</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="/editor"> <i class="ion-compose"></i>&nbsp;New Article </a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="/settings"> <i class="ion-gear-a"></i>&nbsp;Settings </a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="/profile/eric-simons">
          <img src="" class="user-pic" />
          Eric Simons
        </a>
      </li>
    </ul>
  </div>
</nav>
```

### Footer

```html
<footer>
  <div class="container">
    <a href="/" class="logo-font">conduit</a>
    <span class="attribution">
      An interactive learning project. Code &amp; design licensed under MIT.
    </span>
  </div>
</footer>
```

## Pages

### Home

The Home page includes up to three tabs:

- a default **Global Feed** tab
- an optional **tag name** tab, appears after clicking one of the popular tags
- an optional **Your Feed** tab, appears after logging in

```html
<div class="home-page">
  <div class="banner">
    <div class="container">
      <h1 class="logo-font">conduit</h1>
      <p>A place to share your knowledge.</p>
    </div>
  </div>

  <div class="container page">
    <div class="row">
      <div class="col-md-9">
        <div class="feed-toggle">
          <ul class="nav nav-pills outline-active">
            <li class="nav-item">
              <a class="nav-link" href="">Your Feed</a>
            </li>
            <li class="nav-item">
              <a class="nav-link active" href="">Global Feed</a>
            </li>
          </ul>
        </div>

        <div class="article-preview">
          <div class="article-meta">
            <a href="/profile/eric-simons"><img src="http://i.imgur.com/Qr71crq.jpg" /></a>
            <div class="info">
              <a href="/profile/eric-simons" class="author">Eric Simons</a>
              <span class="date">January 20th</span>
            </div>
            <button class="btn btn-outline-primary btn-sm pull-xs-right">
              <i class="ion-heart"></i> 29
            </button>
          </div>
          <a href="/article/how-to-build-webapps-that-scale" class="preview-link">
            <h1>How to build webapps that scale</h1>
            <p>This is the description for the post.</p>
            <span>Read more...</span>
            <ul class="tag-list">
              <li class="tag-default tag-pill tag-outline">realworld</li>
              <li class="tag-default tag-pill tag-outline">implementations</li>
            </ul>
          </a>
        </div>

        <div class="article-preview">
          <div class="article-meta">
            <a href="/profile/albert-pai"><img src="http://i.imgur.com/N4VcUeJ.jpg" /></a>
            <div class="info">
              <a href="/profile/albert-pai" class="author">Albert Pai</a>
              <span class="date">January 20th</span>
            </div>
            <button class="btn btn-outline-primary btn-sm pull-xs-right">
              <i class="ion-heart"></i> 32
            </button>
          </div>
          <a href="/article/the-song-you" class="preview-link">
            <h1>The song you won't ever stop singing. No matter how hard you try.</h1>
            <p>This is the description for the post.</p>
            <span>Read more...</span>
            <ul class="tag-list">
              <li class="tag-default tag-pill tag-outline">realworld</li>
              <li class="tag-default tag-pill tag-outline">implementations</li>
            </ul>
          </a>
        </div>

        <ul class="pagination">
          <li class="page-item active">
            <a class="page-link" href="">1</a>
          </li>
          <li class="page-item">
            <a class="page-link" href="">2</a>
          </li>
        </ul>
      </div>

      <div class="col-md-3">
        <div class="sidebar">
          <p>Popular Tags</p>

          <div class="tag-list">
            <a href="" class="tag-pill tag-default">programming</a>
            <a href="" class="tag-pill tag-default">javascript</a>
            <a href="" class="tag-pill tag-default">emberjs</a>
            <a href="" class="tag-pill tag-default">angularjs</a>
            <a href="" class="tag-pill tag-default">react</a>
            <a href="" class="tag-pill tag-default">mean</a>
            <a href="" class="tag-pill tag-default">node</a>
            <a href="" class="tag-pill tag-default">rails</a>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
```

### Authentication

#### Login

```html
<div class="auth-page">
  <div class="container page">
    <div class="row">
      <div class="col-md-6 offset-md-3 col-xs-12">
        <h1 class="text-xs-center">Sign in</h1>
        <p class="text-xs-center">
          <a href="/register">Need an account?</a>
        </p>

        <ul class="error-messages">
          <li>That email is already taken</li>
        </ul>

        <form>
          <fieldset class="form-group">
            <input class="form-control form-control-lg" type="text" placeholder="Email" />
          </fieldset>
          <fieldset class="form-group">
            <input class="form-control form-control-lg" type="password" placeholder="Password" />
          </fieldset>
          <button class="btn btn-lg btn-primary pull-xs-right">Sign in</button>
        </form>
      </div>
    </div>
  </div>
</div>
```

#### Register

```html
<div class="auth-page">
  <div class="container page">
    <div class="row">
      <div class="col-md-6 offset-md-3 col-xs-12">
        <h1 class="text-xs-center">Sign up</h1>
        <p class="text-xs-center">
          <a href="/login">Have an account?</a>
        </p>

        <ul class="error-messages">
          <li>That email is already taken</li>
        </ul>

        <form>
          <fieldset class="form-group">
            <input class="form-control form-control-lg" type="text" placeholder="Username" />
          </fieldset>
          <fieldset class="form-group">
            <input class="form-control form-control-lg" type="text" placeholder="Email" />
          </fieldset>
          <fieldset class="form-group">
            <input class="form-control form-control-lg" type="password" placeholder="Password" />
          </fieldset>
          <button class="btn btn-lg btn-primary pull-xs-right">Sign up</button>
        </form>
      </div>
    </div>
  </div>
</div>
```

### Profile

```html
<div class="profile-page">
  <div class="user-info">
    <div class="container">
      <div class="row">
        <div class="col-xs-12 col-md-10 offset-md-1">
          <img src="http://i.imgur.com/Qr71crq.jpg" class="user-img" />
          <h4>Eric Simons</h4>
          <p>
            Cofounder @GoThinkster, lived in Aol's HQ for a few months, kinda looks like Peeta from
            the Hunger Games
          </p>
          <button class="btn btn-sm btn-outline-secondary action-btn">
            <i class="ion-plus-round"></i>
            &nbsp; Follow Eric Simons
          </button>
          <button class="btn btn-sm btn-outline-secondary action-btn">
            <i class="ion-gear-a"></i>
            &nbsp; Edit Profile Settings
          </button>
        </div>
      </div>
    </div>
  </div>

  <div class="container">
    <div class="row">
      <div class="col-xs-12 col-md-10 offset-md-1">
        <div class="articles-toggle">
          <ul class="nav nav-pills outline-active">
            <li class="nav-item">
              <a class="nav-link active" href="">My Articles</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="">Favorited Articles</a>
            </li>
          </ul>
        </div>

        <div class="article-preview">
          <div class="article-meta">
            <a href="/profile/eric-simons"><img src="http://i.imgur.com/Qr71crq.jpg" /></a>
            <div class="info">
              <a href="/profile/eric-simons" class="author">Eric Simons</a>
              <span class="date">January 20th</span>
            </div>
            <button class="btn btn-outline-primary btn-sm pull-xs-right">
              <i class="ion-heart"></i> 29
            </button>
          </div>
          <a href="/article/how-to-buil-webapps-that-scale" class="preview-link">
            <h1>How to build webapps that scale</h1>
            <p>This is the description for the post.</p>
            <span>Read more...</span>
            <ul class="tag-list">
              <li class="tag-default tag-pill tag-outline">realworld</li>
              <li class="tag-default tag-pill tag-outline">implementations</li>
            </ul>
          </a>
        </div>

        <div class="article-preview">
          <div class="article-meta">
            <a href="/profile/albert-pai"><img src="http://i.imgur.com/N4VcUeJ.jpg" /></a>
            <div class="info">
              <a href="/profile/albert-pai" class="author">Albert Pai</a>
              <span class="date">January 20th</span>
            </div>
            <button class="btn btn-outline-primary btn-sm pull-xs-right">
              <i class="ion-heart"></i> 32
            </button>
          </div>
          <a href="/article/the-song-you" class="preview-link">
            <h1>The song you won't ever stop singing. No matter how hard you try.</h1>
            <p>This is the description for the post.</p>
            <span>Read more...</span>
            <ul class="tag-list">
              <li class="tag-default tag-pill tag-outline">Music</li>
              <li class="tag-default tag-pill tag-outline">Song</li>
            </ul>
          </a>
        </div>

        <ul class="pagination">
          <li class="page-item active">
            <a class="page-link" href="">1</a>
          </li>
          <li class="page-item">
            <a class="page-link" href="">2</a>
          </li>
        </ul>
      </div>
    </div>
  </div>
</div>
```

### Settings

```html
<div class="settings-page">
  <div class="container page">
    <div class="row">
      <div class="col-md-6 offset-md-3 col-xs-12">
        <h1 class="text-xs-center">Your Settings</h1>

        <ul class="error-messages">
          <li>That name is required</li>
        </ul>

        <form>
          <fieldset>
            <fieldset class="form-group">
              <input class="form-control" type="text" placeholder="URL of profile picture" />
            </fieldset>
            <fieldset class="form-group">
              <input class="form-control form-control-lg" type="text" placeholder="Your Name" />
            </fieldset>
            <fieldset class="form-group">
              <textarea
                class="form-control form-control-lg"
                rows="8"
                placeholder="Short bio about you"
              ></textarea>
            </fieldset>
            <fieldset class="form-group">
              <input class="form-control form-control-lg" type="text" placeholder="Email" />
            </fieldset>
            <fieldset class="form-group">
              <input
                class="form-control form-control-lg"
                type="password"
                placeholder="New Password"
              />
            </fieldset>
            <button class="btn btn-lg btn-primary pull-xs-right">Update Settings</button>
          </fieldset>
        </form>
        <hr />
        <button class="btn btn-outline-danger">Or click here to logout.</button>
      </div>
    </div>
  </div>
</div>
```

### Create/Edit Article

```html
<div class="editor-page">
  <div class="container page">
    <div class="row">
      <div class="col-md-10 offset-md-1 col-xs-12">
        <ul class="error-messages">
          <li>That title is required</li>
        </ul>

        <form>
          <fieldset>
            <fieldset class="form-group">
              <input type="text" class="form-control form-control-lg" placeholder="Article Title" />
            </fieldset>
            <fieldset class="form-group">
              <input type="text" class="form-control" placeholder="What's this article about?" />
            </fieldset>
            <fieldset class="form-group">
              <textarea
                class="form-control"
                rows="8"
                placeholder="Write your article (in markdown)"
              ></textarea>
            </fieldset>
            <fieldset class="form-group">
              <input type="text" class="form-control" placeholder="Enter tags" />
              <div class="tag-list">
                <span class="tag-default tag-pill"> <i class="ion-close-round"></i> tag </span>
              </div>
            </fieldset>
            <button class="btn btn-lg pull-xs-right btn-primary" type="button">
              Publish Article
            </button>
          </fieldset>
        </form>
      </div>
    </div>
  </div>
</div>
```

### Article

// TODO : update to switch between follow/favorite AND edit/delete

```html
<div class="article-page">
  <div class="banner">
    <div class="container">
      <h1>How to build webapps that scale</h1>

      <div class="article-meta">
        <a href="/profile/eric-simons"><img src="http://i.imgur.com/Qr71crq.jpg" /></a>
        <div class="info">
          <a href="/profile/eric-simons" class="author">Eric Simons</a>
          <span class="date">January 20th</span>
        </div>
        <button class="btn btn-sm btn-outline-secondary">
          <i class="ion-plus-round"></i>
          &nbsp; Follow Eric Simons <span class="counter">(10)</span>
        </button>
        &nbsp;&nbsp;
        <button class="btn btn-sm btn-outline-primary">
          <i class="ion-heart"></i>
          &nbsp; Favorite Post <span class="counter">(29)</span>
        </button>
        <button class="btn btn-sm btn-outline-secondary">
          <i class="ion-edit"></i> Edit Article
        </button>
        <button class="btn btn-sm btn-outline-danger">
          <i class="ion-trash-a"></i> Delete Article
        </button>
      </div>
    </div>
  </div>

  <div class="container page">
    <div class="row article-content">
      <div class="col-md-12">
        <p>
          Web development technologies have evolved at an incredible clip over the past few years.
        </p>
        <h2 id="introducing-ionic">Introducing RealWorld.</h2>
        <p>It's a great solution for learning how other frameworks work.</p>
        <ul class="tag-list">
          <li class="tag-default tag-pill tag-outline">realworld</li>
          <li class="tag-default tag-pill tag-outline">implementations</li>
        </ul>
      </div>
    </div>

    <hr />

    <div class="article-actions">
      <div class="article-meta">
        <a href="profile.html"><img src="http://i.imgur.com/Qr71crq.jpg" /></a>
        <div class="info">
          <a href="" class="author">Eric Simons</a>
          <span class="date">January 20th</span>
        </div>

        <button class="btn btn-sm btn-outline-secondary">
          <i class="ion-plus-round"></i>
          &nbsp; Follow Eric Simons
        </button>
        &nbsp;
        <button class="btn btn-sm btn-outline-primary">
          <i class="ion-heart"></i>
          &nbsp; Favorite Article <span class="counter">(29)</span>
        </button>
        <button class="btn btn-sm btn-outline-secondary">
          <i class="ion-edit"></i> Edit Article
        </button>
        <button class="btn btn-sm btn-outline-danger">
          <i class="ion-trash-a"></i> Delete Article
        </button>
      </div>
    </div>

    <div class="row">
      <div class="col-xs-12 col-md-8 offset-md-2">
        <form class="card comment-form">
          <div class="card-block">
            <textarea class="form-control" placeholder="Write a comment..." rows="3"></textarea>
          </div>
          <div class="card-footer">
            <img src="http://i.imgur.com/Qr71crq.jpg" class="comment-author-img" />
            <button class="btn btn-sm btn-primary">Post Comment</button>
          </div>
        </form>

        <div class="card">
          <div class="card-block">
            <p class="card-text">
              With supporting text below as a natural lead-in to additional content.
            </p>
          </div>
          <div class="card-footer">
            <a href="/profile/author" class="comment-author">
              <img src="http://i.imgur.com/Qr71crq.jpg" class="comment-author-img" />
            </a>
            &nbsp;
            <a href="/profile/jacob-schmidt" class="comment-author">Jacob Schmidt</a>
            <span class="date-posted">Dec 29th</span>
          </div>
        </div>

        <div class="card">
          <div class="card-block">
            <p class="card-text">
              With supporting text below as a natural lead-in to additional content.
            </p>
          </div>
          <div class="card-footer">
            <a href="/profile/author" class="comment-author">
              <img src="http://i.imgur.com/Qr71crq.jpg" class="comment-author-img" />
            </a>
            &nbsp;
            <a href="/profile/jacob-schmidt" class="comment-author">Jacob Schmidt</a>
            <span class="date-posted">Dec 29th</span>
            <span class="mod-options">
              <i class="ion-trash-a"></i>
            </span>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
```


================================================
FILE: docs/src/content/docs/specifications/frontend/tests.md
================================================
---
title: Tests
---

Include _at least_ **one** unit test in your repo to demonstrate how testing works (full testing coverage is _not_ required!)

## E2E Tests

A shared [Playwright E2E test suite](https://github.com/realworld-apps/realworld/tree/main/specs/e2e) is available to validate your frontend implementation. It covers authentication, articles, comments, navigation, settings, social features, error handling, and even basic XSS security.

To make your implementation compatible with the E2E tests, it **must** follow the [selectors contract](https://github.com/realworld-apps/realworld/blob/main/specs/e2e/SELECTORS.md), which defines:

- Form input `name` attributes
- Required CSS classes (layout, feed, tags, comments, profile, pagination, buttons, errors)
- Required text content for buttons and links
- Routes
- A debug interface (`window.__conduit_debug__`)
- LocalStorage key for the JWT token
- Default avatar behavior

### Running the tests

The test suite ships with a [base Playwright config](https://github.com/realworld-apps/realworld/blob/main/specs/e2e/playwright.base.ts) that you can extend in your implementation. Override `baseURL` and `webServer` to point to your dev server.

See the [Angular implementation](https://github.com/realworld-apps/angular-realworld-example-app) for a working example.


================================================
FILE: docs/src/content/docs/specifications/mobile-specs/introduction.md
================================================
---
title: Introduction
---

### [Icons for (iOS/Android)](https://github.com/realworld-apps/realworld/tree/master/spec/mobile_icons)

### Styles/Templates

Unfortunately, there isn't a common way for us to reuse & share styles/templates for cross-platform mobile apps.

Instead, we recommend using the Medium.com [iOS](https://itunes.apple.com/us/app/medium/id828256236?mt=8) and [Android](https://play.google.com/store/apps/details?id=com.medium.reader&hl=en) apps as a "north star" regarding general UI functionality/layout, but try not to go too overboard otherwise it will unnecessarily complicate your codebase (in other words, [KISS](https://en.wikipedia.org/wiki/KISS_principle) :)


================================================
FILE: docs/src/env.d.ts
================================================
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />


================================================
FILE: docs/src/tailwind.css
================================================
@import "tailwindcss";
@import "@astrojs/starlight-tailwind";
@source "../**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}";

@theme {
    --color-accent-200: #e3b6ed;
    --color-accent-600: #a700c3;
    --color-accent-900: #4e0e5b;
    --color-accent-950: #36113e;
    --color-gray-100: #f8f4fe;
    --color-gray-200: #f2e9fd;
    --color-gray-300: #c7bdd5;
    --color-gray-400: #9581ae;
    --color-gray-500: #614e78;
    --color-gray-700: #412e55;
    --color-gray-800: #2f1c42;
    --color-gray-900: #1c1425;
}

/* Restore list styles in content area */
.sl-markdown-content ul {
    list-style-type: disc;
    padding-left: 1.5rem;
}

.sl-markdown-content ol {
    list-style-type: decimal;
    padding-left: 1.5rem;
}

.sl-markdown-content ul ul,
.sl-markdown-content ol ul {
    list-style-type: circle;
}

.sl-markdown-content ul ul ul,
.sl-markdown-content ol ol ul,
.sl-markdown-content ol ul ul {
    list-style-type: square;
}


================================================
FILE: docs/tsconfig.json
================================================
{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "react"
  }
}

================================================
FILE: specs/api/README.md
================================================
# RealWorld API Spec

## Running API tests locally

### With Hurl

To locally run the provided [Hurl](https://hurl.dev) collection against your backend, execute:

```
HOST=http://localhost:3000/api ./run-api-tests-hurl.sh
```

For more details, see [`run-api-tests-hurl.sh`](run-api-tests-hurl.sh).

### With Bruno

A [Bruno](https://www.usebruno.com) collection is also available, automatically generated from the Hurl test suite. To run it:

```
HOST=http://localhost:3000/api ./run-api-tests-bruno.sh
```

For more details, see [`run-api-tests-bruno.sh`](run-api-tests-bruno.sh).

You can also open the `bruno/` folder directly in the Bruno app to run and inspect requests interactively.

> **Note:** The Hurl files are the source of truth. The Bruno collection is generated with `make bruno-generate` and kept in sync via CI (`make bruno-check`).


================================================
FILE: specs/api/bruno/articles/01-setup-register.bru
================================================
meta {
  name: Setup: Register
  type: http
  seq: 1
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "art_{{uid}}",
      "email": "art_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("token", res.body.user.token);
}


================================================
FILE: specs/api/bruno/articles/02-create-article-with-tags.bru
================================================
meta {
  name: Create article with tags
  type: http
  seq: 2
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "title": "Test Article {{uid}}",
      "description": "Test description",
      "body": "Test body content",
      "tagList": ["d_{{uid}}", "t_{{uid}}"]
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("slug", res.body.article.slug);
  bru.setVar("created_at", res.body.article.createdAt);
  bru.setVar("updated_at", res.body.article.updatedAt);
  expect(res.body.article.title).to.eql("Test Article " + bru.getVar("uid"));
  expect(typeof res.body.article.slug).to.eql("string");
  expect(res.body.article.description).to.eql("Test description");
  expect(res.body.article.body).to.eql("Test body content");
  expect(res.body.article.tagList).to.include("d_" + bru.getVar("uid"));
  expect(res.body.article.tagList).to.include("t_" + bru.getVar("uid"));
  expect(res.body.article.tagList[0]).to.eql("d_" + bru.getVar("uid"));
  expect(res.body.article.tagList[1]).to.eql("t_" + bru.getVar("uid"));
  expect(res.body.article.createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T/);
  expect(res.body.article.updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T/);
  expect(res.body.article.favorited).to.eql(false);
  expect(res.body.article.favoritesCount).to.eql(0);
  expect(res.body.article.author.username).to.eql("art_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/articles/03-list-all-articles.bru
================================================
meta {
  name: List all articles
  type: http
  seq: 3
}

get {
  url: {{host}}/api/articles
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.articles)).to.eql(true);
  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);
  expect(res.body.articlesCount).to.be.at.least(1);
  expect(typeof res.body.articles[0].title).to.eql("string");
  expect(typeof res.body.articles[0].slug).to.eql("string");
  expect(typeof res.body.articles[0].description).to.eql("string");
  expect(res.body.articles[0]).to.not.have.property("body");
  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);
  expect(res.body.articles[0].createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.articles[0].updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(typeof res.body.articles[0].favorited).to.eql("boolean");
  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);
  expect(typeof res.body.articles[0].author.username).to.eql("string");
}


================================================
FILE: specs/api/bruno/articles/04-list-by-author.bru
================================================
meta {
  name: List by author
  type: http
  seq: 4
}

get {
  url: {{host}}/api/articles?author=art_{{uid}}
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.articles)).to.eql(true);
  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);
  expect(res.body.articlesCount).to.be.at.least(1);
  expect(typeof res.body.articles[0].title).to.eql("string");
  expect(typeof res.body.articles[0].slug).to.eql("string");
  expect(typeof res.body.articles[0].description).to.eql("string");
  expect(res.body.articles[0]).to.not.have.property("body");
  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);
  expect(res.body.articles[0].createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.articles[0].updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(typeof res.body.articles[0].favorited).to.eql("boolean");
  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);
  expect(res.body.articles[0].author.username).to.eql("art_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/articles/05-list-all-articles-with-auth.bru
================================================
meta {
  name: List all articles with auth
  type: http
  seq: 5
}

get {
  url: {{host}}/api/articles
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.articles)).to.eql(true);
  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);
  expect(res.body.articlesCount).to.be.at.least(1);
  expect(typeof res.body.articles[0].title).to.eql("string");
  expect(typeof res.body.articles[0].slug).to.eql("string");
  expect(typeof res.body.articles[0].description).to.eql("string");
  expect(res.body.articles[0]).to.not.have.property("body");
  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);
  expect(res.body.articles[0].createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.articles[0].updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(typeof res.body.articles[0].favorited).to.eql("boolean");
  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);
  expect(typeof res.body.articles[0].author.username).to.eql("string");
}


================================================
FILE: specs/api/bruno/articles/06-list-by-author-with-auth.bru
================================================
meta {
  name: List by author with auth
  type: http
  seq: 6
}

get {
  url: {{host}}/api/articles?author=art_{{uid}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.articles)).to.eql(true);
  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);
  expect(res.body.articlesCount).to.be.at.least(1);
  expect(typeof res.body.articles[0].title).to.eql("string");
  expect(typeof res.body.articles[0].slug).to.eql("string");
  expect(typeof res.body.articles[0].description).to.eql("string");
  expect(res.body.articles[0]).to.not.have.property("body");
  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);
  expect(res.body.articles[0].createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.articles[0].updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(typeof res.body.articles[0].favorited).to.eql("boolean");
  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);
  expect(res.body.articles[0].author.username).to.eql("art_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/articles/07-list-by-tag.bru
================================================
meta {
  name: List by tag
  type: http
  seq: 7
}

get {
  url: {{host}}/api/articles?tag=d_{{uid}}
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.articles)).to.eql(true);
  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);
  expect(res.body.articlesCount).to.be.at.least(1);
  expect(typeof res.body.articles[0].title).to.eql("string");
  expect(typeof res.body.articles[0].slug).to.eql("string");
  expect(typeof res.body.articles[0].description).to.eql("string");
  expect(res.body.articles[0]).to.not.have.property("body");
  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);
  expect(res.body.articles[0].tagList).to.include("d_" + bru.getVar("uid"));
  expect(res.body.articles[0].createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.articles[0].updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(typeof res.body.articles[0].favorited).to.eql("boolean");
  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);
  expect(typeof res.body.articles[0].author.username).to.eql("string");
}


================================================
FILE: specs/api/bruno/articles/08-list-articles-without-auth.bru
================================================
meta {
  name: List articles without auth
  type: http
  seq: 8
}

get {
  url: {{host}}/api/articles
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.articles)).to.eql(true);
  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);
}


================================================
FILE: specs/api/bruno/articles/09-get-single-article.bru
================================================
meta {
  name: Get single article
  type: http
  seq: 9
}

get {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.article.title).to.eql("Test Article " + bru.getVar("uid"));
  expect(res.body.article.slug).to.eql(bru.getVar("slug"));
  expect(res.body.article.description).to.eql("Test description");
  expect(res.body.article.body).to.eql("Test body content");
  expect(Array.isArray(res.body.article.tagList)).to.eql(true);
  expect(res.body.article.createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.article.updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.article.favorited).to.eql(false);
  expect(res.body.article.favoritesCount).to.eql(0);
  expect(res.body.article.author.username).to.eql("art_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/articles/10-update-article-body.bru
================================================
meta {
  name: Update article body
  type: http
  seq: 10
}

put {
  url: {{host}}/api/articles/{{slug}}
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "body": "Updated body content"
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.article.title).to.eql("Test Article " + bru.getVar("uid"));
  expect(res.body.article.slug).to.eql(bru.getVar("slug"));
  expect(res.body.article.description).to.eql("Test description");
  expect(res.body.article.body).to.eql("Updated body content");
  expect(Array.isArray(res.body.article.tagList)).to.eql(true);
  expect(res.body.article.tagList.length).to.eql(2);
  expect(res.body.article.tagList).to.include("d_" + bru.getVar("uid"));
  expect(res.body.article.tagList).to.include("t_" + bru.getVar("uid"));
  expect(res.body.article.createdAt).to.eql(bru.getVar("created_at"));
  expect(res.body.article.updatedAt).to.not.eql(bru.getVar("updated_at"));
  expect(typeof res.body.article.favorited).to.eql("boolean");
  expect(Number.isInteger(res.body.article.favoritesCount)).to.eql(true);
  expect(res.body.article.author.username).to.eql("art_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/articles/11-verify-update-persisted.bru
================================================
meta {
  name: Verify update persisted
  type: http
  seq: 11
}

get {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.article.title).to.eql("Test Article " + bru.getVar("uid"));
  expect(res.body.article.slug).to.eql(bru.getVar("slug"));
  expect(res.body.article.description).to.eql("Test description");
  expect(res.body.article.body).to.eql("Updated body content");
  expect(Array.isArray(res.body.article.tagList)).to.eql(true);
  expect(res.body.article.tagList.length).to.eql(2);
  expect(res.body.article.tagList).to.include("d_" + bru.getVar("uid"));
  expect(res.body.article.tagList).to.include("t_" + bru.getVar("uid"));
  expect(res.body.article.createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.article.updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(typeof res.body.article.favorited).to.eql("boolean");
  expect(Number.isInteger(res.body.article.favoritesCount)).to.eql(true);
  expect(res.body.article.author.username).to.eql("art_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/articles/12-update-article-without-taglist-tags-should-be-preserved.bru
================================================
meta {
  name: Update article without tagList: tags should be preserved
  type: http
  seq: 12
}

put {
  url: {{host}}/api/articles/{{slug}}
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "body": "Body without touching tags"
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.article.body).to.eql("Body without touching tags");
  expect(res.body.article.tagList.length).to.eql(2);
  expect(res.body.article.tagList).to.include("d_" + bru.getVar("uid"));
  expect(res.body.article.tagList).to.include("t_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/articles/13-update-article-remove-all-tags-with-empty-array.bru
================================================
meta {
  name: Update article: remove all tags with empty array
  type: http
  seq: 13
}

put {
  url: {{host}}/api/articles/{{slug}}
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "tagList": []
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.article.tagList)).to.eql(true);
  expect(res.body.article.tagList.length).to.eql(0);
}


================================================
FILE: specs/api/bruno/articles/14-verify-tags-were-actually-removed.bru
================================================
meta {
  name: Verify tags were actually removed
  type: http
  seq: 14
}

get {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.article.tagList)).to.eql(true);
  expect(res.body.article.tagList.length).to.eql(0);
}


================================================
FILE: specs/api/bruno/articles/15-update-article-taglist-null-should-be-rejected.bru
================================================
meta {
  name: Update article: tagList null should be rejected
  type: http
  seq: 15
}

put {
  url: {{host}}/api/articles/{{slug}}
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "tagList": null
    }
  }
}

assert {
  res.status: eq 422
}


================================================
FILE: specs/api/bruno/articles/16-delete-article.bru
================================================
meta {
  name: Delete article
  type: http
  seq: 16
}

delete {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 204
}


================================================
FILE: specs/api/bruno/articles/17-verify-deletion.bru
================================================
meta {
  name: Verify deletion
  type: http
  seq: 17
}

get {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/auth/01-register.bru
================================================
meta {
  name: Register
  type: http
  seq: 1
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "auth_{{uid}}",
      "email": "auth_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("reg_token", res.body.user.token);
  expect(res.body.user.username).to.eql("auth_" + bru.getVar("uid"));
  expect(res.body.user.email).to.eql("auth_" + bru.getVar("uid") + "@test.com");
  expect(res.body.user.bio).to.be.null;
  expect(res.body.user.image).to.be.null;
  expect(typeof res.body.user.token).to.eql("string");
  expect(res.body.user.token).to.not.eql("");
}


================================================
FILE: specs/api/bruno/auth/02-login.bru
================================================
meta {
  name: Login
  type: http
  seq: 2
}

post {
  url: {{host}}/api/users/login
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "email": "auth_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  bru.setVar("token", res.body.user.token);
  expect(res.body.user.username).to.eql("auth_" + bru.getVar("uid"));
  expect(res.body.user.email).to.eql("auth_" + bru.getVar("uid") + "@test.com");
  expect(res.body.user.bio).to.be.null;
  expect(res.body.user.image).to.be.null;
  expect(typeof res.body.user.token).to.eql("string");
  expect(res.body.user.token).to.not.eql("");
}


================================================
FILE: specs/api/bruno/auth/03-get-current-user.bru
================================================
meta {
  name: Get current user
  type: http
  seq: 3
}

get {
  url: {{host}}/api/user
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.username).to.eql("auth_" + bru.getVar("uid"));
  expect(res.body.user.email).to.eql("auth_" + bru.getVar("uid") + "@test.com");
  expect(res.body.user.bio).to.be.null;
  expect(res.body.user.image).to.be.null;
  expect(typeof res.body.user.token).to.eql("string");
  expect(res.body.user.token).to.not.eql("");
}


================================================
FILE: specs/api/bruno/auth/04-update-user.bru
================================================
meta {
  name: Update user
  type: http
  seq: 4
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "bio": "Updated bio"
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.username).to.eql("auth_" + bru.getVar("uid"));
  expect(res.body.user.email).to.eql("auth_" + bru.getVar("uid") + "@test.com");
  expect(res.body.user.bio).to.eql("Updated bio");
  expect(res.body.user.image).to.be.null;
  expect(typeof res.body.user.token).to.eql("string");
  expect(res.body.user.token).to.not.eql("");
}


================================================
FILE: specs/api/bruno/auth/05-verify-update-persisted.bru
================================================
meta {
  name: Verify update persisted
  type: http
  seq: 5
}

get {
  url: {{host}}/api/user
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.username).to.eql("auth_" + bru.getVar("uid"));
  expect(res.body.user.email).to.eql("auth_" + bru.getVar("uid") + "@test.com");
  expect(res.body.user.bio).to.eql("Updated bio");
  expect(res.body.user.image).to.be.null;
  expect(typeof res.body.user.token).to.eql("string");
  expect(res.body.user.token).to.not.eql("");
}


================================================
FILE: specs/api/bruno/auth/06-update-user-bio-to-empty-string-should-normalize-to-null.bru
================================================
meta {
  name: Update user bio to empty string - should normalize to null
  type: http
  seq: 6
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "bio": ""
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.bio).to.be.null;
}


================================================
FILE: specs/api/bruno/auth/07-verify-empty-string-normalization-persisted.bru
================================================
meta {
  name: Verify empty string normalization persisted
  type: http
  seq: 7
}

get {
  url: {{host}}/api/user
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.bio).to.be.null;
}


================================================
FILE: specs/api/bruno/auth/08-restore-bio-then-set-to-null.bru
================================================
meta {
  name: Restore bio then set to null
  type: http
  seq: 8
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "bio": "Temporary bio"
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.bio).to.eql("Temporary bio");
}


================================================
FILE: specs/api/bruno/auth/09-update-user-bio-to-null-should-accept-for-nullable-field.bru
================================================
meta {
  name: Update user bio to null - should accept for nullable field
  type: http
  seq: 9
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "bio": null
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.bio).to.be.null;
}


================================================
FILE: specs/api/bruno/auth/10-verify-null-bio-persisted.bru
================================================
meta {
  name: Verify null bio persisted
  type: http
  seq: 10
}

get {
  url: {{host}}/api/user
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.bio).to.be.null;
}


================================================
FILE: specs/api/bruno/auth/11-restore-bio.bru
================================================
meta {
  name: Restore bio
  type: http
  seq: 11
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "bio": "Updated bio"
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.username).to.eql("auth_" + bru.getVar("uid"));
  expect(res.body.user.email).to.eql("auth_" + bru.getVar("uid") + "@test.com");
  expect(res.body.user.bio).to.eql("Updated bio");
  expect(res.body.user.image).to.be.null;
  expect(typeof res.body.user.token).to.eql("string");
  expect(res.body.user.token).to.not.eql("");
}


================================================
FILE: specs/api/bruno/auth/12-update-user-image.bru
================================================
meta {
  name: Update user image
  type: http
  seq: 12
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "image": "https://example.com/photo.jpg"
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.image).to.eql("https://example.com/photo.jpg");
}


================================================
FILE: specs/api/bruno/auth/13-verify-image-update-persisted.bru
================================================
meta {
  name: Verify image update persisted
  type: http
  seq: 13
}

get {
  url: {{host}}/api/user
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.image).to.eql("https://example.com/photo.jpg");
}


================================================
FILE: specs/api/bruno/auth/14-update-image-to-empty-string-should-normalize-to-null.bru
================================================
meta {
  name: Update image to empty string - should normalize to null
  type: http
  seq: 14
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "image": ""
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.image).to.be.null;
}


================================================
FILE: specs/api/bruno/auth/15-verify-image-empty-string-normalization-persisted.bru
================================================
meta {
  name: Verify image empty string normalization persisted
  type: http
  seq: 15
}

get {
  url: {{host}}/api/user
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.image).to.be.null;
}


================================================
FILE: specs/api/bruno/auth/16-set-image-then-update-to-null-should-accept-for-nullable-field.bru
================================================
meta {
  name: Set image then update to null - should accept for nullable field
  type: http
  seq: 16
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "image": "https://example.com/temp.jpg"
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.image).to.eql("https://example.com/temp.jpg");
}


================================================
FILE: specs/api/bruno/auth/17-put-user.bru
================================================
meta {
  name: PUT user
  type: http
  seq: 17
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "image": null
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.image).to.be.null;
}


================================================
FILE: specs/api/bruno/auth/18-verify-null-image-persisted.bru
================================================
meta {
  name: Verify null image persisted
  type: http
  seq: 18
}

get {
  url: {{host}}/api/user
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.image).to.be.null;
}


================================================
FILE: specs/api/bruno/auth/19-update-username-and-email.bru
================================================
meta {
  name: Update username and email
  type: http
  seq: 19
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "username": "auth_{{uid}}_upd",
      "email": "auth_{{uid}}_upd@test.com"
    }
  }
}

assert {
  res.status: eq 200
}

script:post-response {
  bru.setVar("updated_token", res.body.user.token);
  expect(res.body.user.username).to.eql("auth_" + bru.getVar("uid") + "_upd");
  expect(res.body.user.email).to.eql("auth_" + bru.getVar("uid") + "_upd@test.com");
  expect(res.body.user.bio).to.eql("Updated bio");
  expect(res.body.user.image).to.be.null;
  expect(typeof res.body.user.token).to.eql("string");
  expect(res.body.user.token).to.not.eql("");
}


================================================
FILE: specs/api/bruno/auth/20-verify-username-email-update-persisted.bru
================================================
meta {
  name: Verify username/email update persisted
  type: http
  seq: 20
}

get {
  url: {{host}}/api/user
  body: none
  auth: none
}

headers {
  Authorization: Token {{updated_token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.user.username).to.eql("auth_" + bru.getVar("uid") + "_upd");
  expect(res.body.user.email).to.eql("auth_" + bru.getVar("uid") + "_upd@test.com");
  expect(res.body.user.bio).to.eql("Updated bio");
  expect(res.body.user.image).to.be.null;
  expect(typeof res.body.user.token).to.eql("string");
  expect(res.body.user.token).to.not.eql("");
}


================================================
FILE: specs/api/bruno/bruno.json
================================================
{
  "version": "1",
  "name": "RealWorld API",
  "type": "collection"
}


================================================
FILE: specs/api/bruno/collection.bru
================================================
script:pre-request {
  if (!bru.getVar("uid")) {
    bru.setVar("uid", Date.now().toString() + Math.random().toString(36).substring(2, 6));
  }
}


================================================
FILE: specs/api/bruno/comments/01-setup-register.bru
================================================
meta {
  name: Setup: Register
  type: http
  seq: 1
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "cmt_{{uid}}",
      "email": "cmt_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("token", res.body.user.token);
}


================================================
FILE: specs/api/bruno/comments/02-setup-create-article.bru
================================================
meta {
  name: Setup: Create article
  type: http
  seq: 2
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "title": "Comment Article {{uid}}",
      "description": "For comments",
      "body": "Article body"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("slug", res.body.article.slug);
}


================================================
FILE: specs/api/bruno/comments/03-create-comment.bru
================================================
meta {
  name: Create comment
  type: http
  seq: 3
}

post {
  url: {{host}}/api/articles/{{slug}}/comments
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "comment": {
      "body": "Test comment body"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("comment_id", res.body.comment.id);
  expect(Number.isInteger(res.body.comment.id)).to.eql(true);
  expect(res.body.comment.body).to.eql("Test comment body");
  expect(res.body.comment.createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.comment.updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.comment.author.username).to.eql("cmt_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/comments/04-list-comments.bru
================================================
meta {
  name: List comments
  type: http
  seq: 4
}

get {
  url: {{host}}/api/articles/{{slug}}/comments
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.comments)).to.eql(true);
  expect(res.body.comments.length).to.eql(1);
  expect(res.body.comments[0].id).to.eql(bru.getVar("comment_id"));
  expect(res.body.comments[0].body).to.eql("Test comment body");
  expect(res.body.comments[0].createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.comments[0].updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.comments[0].author.username).to.eql("cmt_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/comments/05-list-comments-without-auth.bru
================================================
meta {
  name: List comments without auth
  type: http
  seq: 5
}

get {
  url: {{host}}/api/articles/{{slug}}/comments
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.comments)).to.eql(true);
  expect(res.body.comments.length).to.eql(1);
  expect(Number.isInteger(res.body.comments[0].id)).to.eql(true);
  expect(res.body.comments[0].body).to.eql("Test comment body");
  expect(res.body.comments[0].createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.comments[0].updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.comments[0].author.username).to.eql("cmt_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/comments/06-delete-comment.bru
================================================
meta {
  name: Delete comment
  type: http
  seq: 6
}

delete {
  url: {{host}}/api/articles/{{slug}}/comments/{{comment_id}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 204
}


================================================
FILE: specs/api/bruno/comments/07-verify-deletion.bru
================================================
meta {
  name: Verify deletion
  type: http
  seq: 7
}

get {
  url: {{host}}/api/articles/{{slug}}/comments
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.comments.length).to.eql(0);
}


================================================
FILE: specs/api/bruno/comments/08-selective-deletion-create-two-comments-delete-one-verify-the-other-remains.bru
================================================
meta {
  name: Selective deletion: create two comments, delete one, verify the other remains
  type: http
  seq: 8
}

post {
  url: {{host}}/api/articles/{{slug}}/comments
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "comment": {
      "body": "First comment"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("first_comment_id", res.body.comment.id);
}


================================================
FILE: specs/api/bruno/comments/09-post-comments.bru
================================================
meta {
  name: POST comments
  type: http
  seq: 9
}

post {
  url: {{host}}/api/articles/{{slug}}/comments
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "comment": {
      "body": "Second comment"
    }
  }
}

assert {
  res.status: eq 201
}


================================================
FILE: specs/api/bruno/comments/10-verify-two-comments-exist.bru
================================================
meta {
  name: Verify two comments exist
  type: http
  seq: 10
}

get {
  url: {{host}}/api/articles/{{slug}}/comments
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.comments.length).to.eql(2);
}


================================================
FILE: specs/api/bruno/comments/11-delete-the-first-comment.bru
================================================
meta {
  name: Delete the first comment
  type: http
  seq: 11
}

delete {
  url: {{host}}/api/articles/{{slug}}/comments/{{first_comment_id}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 204
}


================================================
FILE: specs/api/bruno/comments/12-verify-only-the-second-comment-remains.bru
================================================
meta {
  name: Verify only the second comment remains
  type: http
  seq: 12
}

get {
  url: {{host}}/api/articles/{{slug}}/comments
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.comments.length).to.eql(1);
  expect(res.body.comments[0].body).to.eql("Second comment");
}


================================================
FILE: specs/api/bruno/comments/13-cleanup.bru
================================================
meta {
  name: Cleanup
  type: http
  seq: 13
}

delete {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 204
}


================================================
FILE: specs/api/bruno/environments/local.bru
================================================
vars {
  host: http://localhost:3000
}


================================================
FILE: specs/api/bruno/errors-articles/01-create-article-no-auth.bru
================================================
meta {
  name: Create article no auth
  type: http
  seq: 1
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

body:json {
  {
    "article": {
      "title": "No Auth Article",
      "description": "test",
      "body": "test"
    }
  }
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-articles/02-get-unknown-slug.bru
================================================
meta {
  name: GET unknown slug
  type: http
  seq: 2
}

get {
  url: {{host}}/api/articles/unknown-slug-{{uid}}
  body: none
  auth: none
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-articles/03-update-no-auth.bru
================================================
meta {
  name: Update no auth
  type: http
  seq: 3
}

put {
  url: {{host}}/api/articles/some-slug
  body: json
  auth: none
}

body:json {
  {
    "article": {
      "body": "test"
    }
  }
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-articles/04-delete-no-auth.bru
================================================
meta {
  name: Delete no auth
  type: http
  seq: 4
}

delete {
  url: {{host}}/api/articles/some-slug
  body: none
  auth: none
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-articles/05-get-feed-no-auth.bru
================================================
meta {
  name: GET feed no auth
  type: http
  seq: 5
}

get {
  url: {{host}}/api/articles/feed
  body: none
  auth: none
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-articles/06-favorite-no-auth.bru
================================================
meta {
  name: Favorite no auth
  type: http
  seq: 6
}

post {
  url: {{host}}/api/articles/some-slug/favorite
  body: none
  auth: none
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-articles/07-unfavorite-no-auth.bru
================================================
meta {
  name: Unfavorite no auth
  type: http
  seq: 7
}

delete {
  url: {{host}}/api/articles/some-slug/favorite
  body: none
  auth: none
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-articles/08-setup-register-for-authenticated-error-tests.bru
================================================
meta {
  name: Setup: Register for authenticated error tests
  type: http
  seq: 8
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "ea_art_{{uid}}",
      "email": "ea_art_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("token", res.body.user.token);
}


================================================
FILE: specs/api/bruno/errors-articles/09-create-article-empty-title.bru
================================================
meta {
  name: Create article empty title
  type: http
  seq: 9
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "title": "",
      "description": "test",
      "body": "test"
    }
  }
}

assert {
  res.status: eq 422
}

script:post-response {
  expect(res.body.errors.title[0]).to.eql("can't be blank");
}


================================================
FILE: specs/api/bruno/errors-articles/10-create-article-empty-description.bru
================================================
meta {
  name: Create article empty description
  type: http
  seq: 10
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "title": "Err Desc {{uid}}",
      "description": "",
      "body": "test"
    }
  }
}

assert {
  res.status: eq 422
}

script:post-response {
  expect(res.body.errors.description[0]).to.eql("can't be blank");
}


================================================
FILE: specs/api/bruno/errors-articles/11-create-article-empty-body.bru
================================================
meta {
  name: Create article empty body
  type: http
  seq: 11
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "title": "Err Body {{uid}}",
      "description": "test",
      "body": ""
    }
  }
}

assert {
  res.status: eq 422
}

script:post-response {
  expect(res.body.errors.body[0]).to.eql("can't be blank");
}


================================================
FILE: specs/api/bruno/errors-articles/12-duplicate-titles-are-allowed-each-gets-a-unique-slug.bru
================================================
meta {
  name: Duplicate titles are allowed (each gets a unique slug)
  type: http
  seq: 12
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "title": "Dup Title {{uid}}",
      "description": "first",
      "body": "first"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("slug1", res.body.article.slug);
}


================================================
FILE: specs/api/bruno/errors-articles/13-post-articles.bru
================================================
meta {
  name: POST articles
  type: http
  seq: 13
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "title": "Dup Title {{uid}}",
      "description": "second",
      "body": "second"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("slug2", res.body.article.slug);
  expect(res.body.article.slug).to.not.eql(bru.getVar("slug1"));
}


================================================
FILE: specs/api/bruno/errors-articles/14-update-unknown-slug.bru
================================================
meta {
  name: Update unknown slug
  type: http
  seq: 14
}

put {
  url: {{host}}/api/articles/unknown-slug-{{uid}}
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "body": "test"
    }
  }
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-articles/15-favorite-unknown-slug.bru
================================================
meta {
  name: Favorite unknown slug
  type: http
  seq: 15
}

post {
  url: {{host}}/api/articles/unknown-slug-{{uid}}/favorite
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-articles/16-unfavorite-unknown-slug.bru
================================================
meta {
  name: Unfavorite unknown slug
  type: http
  seq: 16
}

delete {
  url: {{host}}/api/articles/unknown-slug-{{uid}}/favorite
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-articles/17-update-unknown-slug.bru
================================================
meta {
  name: Update unknown slug
  type: http
  seq: 17
}

put {
  url: {{host}}/api/articles/unknown-slug-{{uid}}
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "body": "test"
    }
  }
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-articles/18-delete-unknown-slug.bru
================================================
meta {
  name: Delete unknown slug
  type: http
  seq: 18
}

delete {
  url: {{host}}/api/articles/unknown-slug-{{uid}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-articles/19-cleanup.bru
================================================
meta {
  name: Cleanup
  type: http
  seq: 19
}

delete {
  url: {{host}}/api/articles/{{slug1}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 204
}


================================================
FILE: specs/api/bruno/errors-articles/20-delete-slug2.bru
================================================
meta {
  name: DELETE {{slug2}}
  type: http
  seq: 20
}

delete {
  url: {{host}}/api/articles/{{slug2}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 204
}


================================================
FILE: specs/api/bruno/errors-auth/01-register-empty-username.bru
================================================
meta {
  name: Register empty username
  type: http
  seq: 1
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "",
      "email": "ea_blank_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 422
}

script:post-response {
  expect(res.body.errors.username[0]).to.eql("can't be blank");
}


================================================
FILE: specs/api/bruno/errors-auth/02-register-empty-email.bru
================================================
meta {
  name: Register empty email
  type: http
  seq: 2
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "ea_blank_{{uid}}",
      "email": "",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 422
}

script:post-response {
  expect(res.body.errors.email[0]).to.eql("can't be blank");
}


================================================
FILE: specs/api/bruno/errors-auth/03-register-empty-password.bru
================================================
meta {
  name: Register empty password
  type: http
  seq: 3
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "ea_blankp_{{uid}}",
      "email": "ea_blankp_{{uid}}@test.com",
      "password": ""
    }
  }
}

assert {
  res.status: eq 422
}

script:post-response {
  expect(res.body.errors.password[0]).to.eql("can't be blank");
}


================================================
FILE: specs/api/bruno/errors-auth/04-register-valid-user-for-duplicate-and-login-tests.bru
================================================
meta {
  name: Register valid user for duplicate and login tests
  type: http
  seq: 4
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "ea_dup_{{uid}}",
      "email": "ea_dup_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("token", res.body.user.token);
}


================================================
FILE: specs/api/bruno/errors-auth/05-register-duplicate-username.bru
================================================
meta {
  name: Register duplicate username
  type: http
  seq: 5
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "ea_dup_{{uid}}",
      "email": "ea_dup2_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 409
}

script:post-response {
  expect(res.body.errors.username[0]).to.eql("has already been taken");
}


================================================
FILE: specs/api/bruno/errors-auth/06-register-duplicate-email.bru
================================================
meta {
  name: Register duplicate email
  type: http
  seq: 6
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "ea_dup2_{{uid}}",
      "email": "ea_dup_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 409
}

script:post-response {
  expect(res.body.errors.email[0]).to.eql("has already been taken");
}


================================================
FILE: specs/api/bruno/errors-auth/07-login-empty-email.bru
================================================
meta {
  name: Login empty email
  type: http
  seq: 7
}

post {
  url: {{host}}/api/users/login
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "email": "",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 422
}

script:post-response {
  expect(res.body.errors.email[0]).to.eql("can't be blank");
}


================================================
FILE: specs/api/bruno/errors-auth/08-login-empty-password.bru
================================================
meta {
  name: Login empty password
  type: http
  seq: 8
}

post {
  url: {{host}}/api/users/login
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "email": "ea_dup_{{uid}}@test.com",
      "password": ""
    }
  }
}

assert {
  res.status: eq 422
}

script:post-response {
  expect(res.body.errors.password[0]).to.eql("can't be blank");
}


================================================
FILE: specs/api/bruno/errors-auth/09-login-wrong-password.bru
================================================
meta {
  name: Login wrong password
  type: http
  seq: 9
}

post {
  url: {{host}}/api/users/login
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "email": "ea_dup_{{uid}}@test.com",
      "password": "wrongpassword"
    }
  }
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.credentials[0]).to.eql("invalid");
}


================================================
FILE: specs/api/bruno/errors-auth/10-get-user-no-auth.bru
================================================
meta {
  name: GET /user no auth
  type: http
  seq: 10
}

get {
  url: {{host}}/api/user
  body: none
  auth: none
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-auth/11-put-user-no-auth.bru
================================================
meta {
  name: PUT /user no auth
  type: http
  seq: 11
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "bio": "test"
    }
  }
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-auth/12-update-email-to-empty-string-should-reject.bru
================================================
meta {
  name: Update email to empty string - should reject
  type: http
  seq: 12
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "email": ""
    }
  }
}

assert {
  res.status: eq 422
}


================================================
FILE: specs/api/bruno/errors-auth/13-update-username-to-empty-string-should-reject.bru
================================================
meta {
  name: Update username to empty string - should reject
  type: http
  seq: 13
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "username": ""
    }
  }
}

assert {
  res.status: eq 422
}


================================================
FILE: specs/api/bruno/errors-auth/14-update-email-to-null-should-reject.bru
================================================
meta {
  name: Update email to null - should reject
  type: http
  seq: 14
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "email": null
    }
  }
}

assert {
  res.status: eq 422
}


================================================
FILE: specs/api/bruno/errors-auth/15-update-username-to-null-should-reject.bru
================================================
meta {
  name: Update username to null - should reject
  type: http
  seq: 15
}

put {
  url: {{host}}/api/user
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "user": {
      "username": null
    }
  }
}

assert {
  res.status: eq 422
}


================================================
FILE: specs/api/bruno/errors-authorization/01-register-user-a.bru
================================================
meta {
  name: Register user A
  type: http
  seq: 1
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "authz_a_{{uid}}",
      "email": "authz_a_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("token_a", res.body.user.token);
}


================================================
FILE: specs/api/bruno/errors-authorization/02-register-user-b.bru
================================================
meta {
  name: Register user B
  type: http
  seq: 2
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "authz_b_{{uid}}",
      "email": "authz_b_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("token_b", res.body.user.token);
}


================================================
FILE: specs/api/bruno/errors-authorization/03-user-a-creates-article.bru
================================================
meta {
  name: User A creates article
  type: http
  seq: 3
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token_a}}
}

body:json {
  {
    "article": {
      "title": "Authz Article {{uid}}",
      "description": "test",
      "body": "test"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("slug", res.body.article.slug);
}


================================================
FILE: specs/api/bruno/errors-authorization/04-user-b-tries-to-delete-403.bru
================================================
meta {
  name: User B tries to delete -> 403
  type: http
  seq: 4
}

delete {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token_b}}
}

assert {
  res.status: eq 403
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("forbidden");
}


================================================
FILE: specs/api/bruno/errors-authorization/05-user-b-tries-to-update-403.bru
================================================
meta {
  name: User B tries to update -> 403
  type: http
  seq: 5
}

put {
  url: {{host}}/api/articles/{{slug}}
  body: json
  auth: none
}

headers {
  Authorization: Token {{token_b}}
}

body:json {
  {
    "article": {
      "body": "hijacked"
    }
  }
}

assert {
  res.status: eq 403
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("forbidden");
}


================================================
FILE: specs/api/bruno/errors-authorization/06-user-a-creates-a-comment-on-the-article.bru
================================================
meta {
  name: User A creates a comment on the article
  type: http
  seq: 6
}

post {
  url: {{host}}/api/articles/{{slug}}/comments
  body: json
  auth: none
}

headers {
  Authorization: Token {{token_a}}
}

body:json {
  {
    "comment": {
      "body": "A's comment"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("comment_id", res.body.comment.id);
}


================================================
FILE: specs/api/bruno/errors-authorization/07-user-b-tries-to-delete-a-s-comment-403.bru
================================================
meta {
  name: User B tries to delete A's comment -> 403
  type: http
  seq: 7
}

delete {
  url: {{host}}/api/articles/{{slug}}/comments/{{comment_id}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token_b}}
}

assert {
  res.status: eq 403
}

script:post-response {
  expect(res.body.errors.comment[0]).to.eql("forbidden");
}


================================================
FILE: specs/api/bruno/errors-authorization/08-verify-comment-survived-the-failed-delete.bru
================================================
meta {
  name: Verify comment survived the failed delete
  type: http
  seq: 8
}

get {
  url: {{host}}/api/articles/{{slug}}/comments
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(res.body.comments.length).to.be.at.least(1);
  expect(res.body.comments[0].body).to.eql("A's comment");
}


================================================
FILE: specs/api/bruno/errors-authorization/09-cleanup-user-a-deletes-article.bru
================================================
meta {
  name: Cleanup: User A deletes article
  type: http
  seq: 9
}

delete {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token_a}}
}

assert {
  res.status: eq 204
}


================================================
FILE: specs/api/bruno/errors-comments/01-post-comment-no-auth.bru
================================================
meta {
  name: Post comment no auth
  type: http
  seq: 1
}

post {
  url: {{host}}/api/articles/some-slug/comments
  body: json
  auth: none
}

body:json {
  {
    "comment": {
      "body": "test"
    }
  }
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-comments/02-delete-comment-no-auth.bru
================================================
meta {
  name: Delete comment no auth
  type: http
  seq: 2
}

delete {
  url: {{host}}/api/articles/some-slug/comments/1
  body: none
  auth: none
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-comments/03-setup-register-create-article.bru
================================================
meta {
  name: Setup: Register + create article
  type: http
  seq: 3
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "ec_{{uid}}",
      "email": "ec_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("token", res.body.user.token);
}


================================================
FILE: specs/api/bruno/errors-comments/04-post-articles.bru
================================================
meta {
  name: POST articles
  type: http
  seq: 4
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "title": "Err Comment Art {{uid}}",
      "description": "test",
      "body": "test"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("slug", res.body.article.slug);
}


================================================
FILE: specs/api/bruno/errors-comments/05-post-comment-empty-body.bru
================================================
meta {
  name: Post comment empty body
  type: http
  seq: 5
}

post {
  url: {{host}}/api/articles/{{slug}}/comments
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "comment": {
      "body": ""
    }
  }
}

assert {
  res.status: eq 422
}

script:post-response {
  expect(res.body.errors.body[0]).to.eql("can't be blank");
}


================================================
FILE: specs/api/bruno/errors-comments/06-post-comment-on-unknown-article.bru
================================================
meta {
  name: Post comment on unknown article
  type: http
  seq: 6
}

post {
  url: {{host}}/api/articles/unknown-slug-{{uid}}/comments
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "comment": {
      "body": "orphan"
    }
  }
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-comments/07-get-comments-on-unknown-article.bru
================================================
meta {
  name: Get comments on unknown article
  type: http
  seq: 7
}

get {
  url: {{host}}/api/articles/unknown-slug-{{uid}}/comments
  body: none
  auth: none
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-comments/08-delete-comment-on-unknown-article.bru
================================================
meta {
  name: Delete comment on unknown article
  type: http
  seq: 8
}

delete {
  url: {{host}}/api/articles/unknown-slug-{{uid}}/comments/99999
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.article[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-comments/09-delete-non-existent-comment-on-existing-article.bru
================================================
meta {
  name: Delete non-existent comment on existing article
  type: http
  seq: 9
}

delete {
  url: {{host}}/api/articles/{{slug}}/comments/99999
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.comment[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-comments/10-cleanup.bru
================================================
meta {
  name: Cleanup
  type: http
  seq: 10
}

delete {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 204
}


================================================
FILE: specs/api/bruno/errors-profiles/01-get-unknown-profile.bru
================================================
meta {
  name: GET unknown profile
  type: http
  seq: 1
}

get {
  url: {{host}}/api/profiles/unknown-user-{{uid}}
  body: none
  auth: none
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.profile[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-profiles/02-follow-no-auth.bru
================================================
meta {
  name: Follow no auth
  type: http
  seq: 2
}

post {
  url: {{host}}/api/profiles/unknown-user-{{uid}}/follow
  body: none
  auth: none
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-profiles/03-unfollow-no-auth.bru
================================================
meta {
  name: Unfollow no auth
  type: http
  seq: 3
}

delete {
  url: {{host}}/api/profiles/unknown-user-{{uid}}/follow
  body: none
  auth: none
}

assert {
  res.status: eq 401
}

script:post-response {
  expect(res.body.errors.token[0]).to.eql("is missing");
}


================================================
FILE: specs/api/bruno/errors-profiles/04-setup-register-for-authenticated-404-tests.bru
================================================
meta {
  name: Setup: Register for authenticated 404 tests
  type: http
  seq: 4
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "ep_{{uid}}",
      "email": "ep_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("token", res.body.user.token);
}


================================================
FILE: specs/api/bruno/errors-profiles/05-follow-unknown-user-authed.bru
================================================
meta {
  name: Follow unknown user (authed)
  type: http
  seq: 5
}

post {
  url: {{host}}/api/profiles/unknown-user-{{uid}}/follow
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.profile[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/errors-profiles/06-unfollow-unknown-user-authed.bru
================================================
meta {
  name: Unfollow unknown user (authed)
  type: http
  seq: 6
}

delete {
  url: {{host}}/api/profiles/unknown-user-{{uid}}/follow
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 404
}

script:post-response {
  expect(res.body.errors.profile[0]).to.eql("not found");
}


================================================
FILE: specs/api/bruno/favorites/01-setup-register.bru
================================================
meta {
  name: Setup: Register
  type: http
  seq: 1
}

post {
  url: {{host}}/api/users
  body: json
  auth: none
}

body:json {
  {
    "user": {
      "username": "fav_{{uid}}",
      "email": "fav_{{uid}}@test.com",
      "password": "password123"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("token", res.body.user.token);
}


================================================
FILE: specs/api/bruno/favorites/02-setup-create-article.bru
================================================
meta {
  name: Setup: Create article
  type: http
  seq: 2
}

post {
  url: {{host}}/api/articles
  body: json
  auth: none
}

headers {
  Authorization: Token {{token}}
}

body:json {
  {
    "article": {
      "title": "Favorite Article {{uid}}",
      "description": "For favorites",
      "body": "Article body"
    }
  }
}

assert {
  res.status: eq 201
}

script:post-response {
  bru.setVar("slug", res.body.article.slug);
}


================================================
FILE: specs/api/bruno/favorites/03-favorite-article.bru
================================================
meta {
  name: Favorite article
  type: http
  seq: 3
}

post {
  url: {{host}}/api/articles/{{slug}}/favorite
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(typeof res.body.article.title).to.eql("string");
  expect(typeof res.body.article.slug).to.eql("string");
  expect(typeof res.body.article.description).to.eql("string");
  expect(typeof res.body.article.body).to.eql("string");
  expect(Array.isArray(res.body.article.tagList)).to.eql(true);
  expect(res.body.article.createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.article.updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.article.favorited).to.eql(true);
  expect(res.body.article.favoritesCount).to.eql(1);
  expect(res.body.article.author.username).to.eql("fav_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/favorites/04-verify-favorite-persists.bru
================================================
meta {
  name: Verify favorite persists
  type: http
  seq: 4
}

get {
  url: {{host}}/api/articles/{{slug}}
  body: none
  auth: none
}

headers {
  Authorization: Token {{token}}
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(typeof res.body.article.title).to.eql("string");
  expect(typeof res.body.article.slug).to.eql("string");
  expect(typeof res.body.article.description).to.eql("string");
  expect(typeof res.body.article.body).to.eql("string");
  expect(Array.isArray(res.body.article.tagList)).to.eql(true);
  expect(res.body.article.createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.article.updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.article.favorited).to.eql(true);
  expect(res.body.article.favoritesCount).to.eql(1);
  expect(res.body.article.author.username).to.eql("fav_" + bru.getVar("uid"));
}


================================================
FILE: specs/api/bruno/favorites/05-articles-filtered-by-favorited-username.bru
================================================
meta {
  name: Articles filtered by favorited username
  type: http
  seq: 5
}

get {
  url: {{host}}/api/articles?favorited=fav_{{uid}}
  body: none
  auth: none
}

assert {
  res.status: eq 200
}

script:post-response {
  expect(Array.isArray(res.body.articles)).to.eql(true);
  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);
  expect(res.body.articlesCount).to.be.at.least(1);
  expect(typeof res.body.articles[0].title).to.eql("string");
  expect(typeof res.body.articles[0].slug).to.eql("string");
  expect(typeof res.body.articles[0].description).to.eql("string");
  expect(res.body.articles[0]).to.not.have.property("body");
  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);
  expect(res.body.articles[0].createdAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(res.body.articles[0].updatedAt).to.match(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);
  expect(typeof res.body.articles[0].favorited).to.eql("boolean");
  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);
  expect(res.body.articles[0].favoritesCount).to.be.at.least(1);
}


================================================
Download .txt
gitextract_60zq8mk5/

├── .github/
│   ├── CODEOWNERS
│   ├── ISSUE_TEMPLATE/
│   │   ├── BUG_REPORT.yml
│   │   └── FEATURE_REQUEST.yml
│   ├── dependabot.yml
│   └── workflows/
│       ├── bruno-check.yml
│       ├── codeql.yml
│       ├── deploy-docs.yml
│       └── spammy-guardian.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── assets/
│   ├── media/
│   │   ├── conduit-logo.svg.generate.ts
│   │   └── mobile_icons/
│   │       ├── ios/
│   │       │   ├── AppIcon.appiconset/
│   │       │   │   └── Contents.json
│   │       │   └── README.md
│   │       └── watchkit/
│   │           └── AppIcon.appiconset/
│   │               └── Contents.json
│   └── theme/
│       └── styles.css
├── docs/
│   ├── .gitignore
│   ├── .vscode/
│   │   ├── extensions.json
│   │   └── launch.json
│   ├── README.md
│   ├── astro.config.mjs
│   ├── non-included/
│   │   └── LICENSES_LOGOS.md
│   ├── package.json
│   ├── src/
│   │   ├── content/
│   │   │   ├── config.ts
│   │   │   └── docs/
│   │   │       ├── community/
│   │   │       │   ├── authors.md
│   │   │       │   ├── resources.md
│   │   │       │   └── special-thanks.md
│   │   │       ├── implementation-creation/
│   │   │       │   ├── expectations.md
│   │   │       │   ├── features.md
│   │   │       │   └── introduction.md
│   │   │       ├── index.mdx
│   │   │       ├── introduction.mdx
│   │   │       └── specifications/
│   │   │           ├── backend/
│   │   │           │   ├── api-response-format.md
│   │   │           │   ├── bruno.md
│   │   │           │   ├── cors.md
│   │   │           │   ├── endpoints.md
│   │   │           │   ├── error-handling.md
│   │   │           │   ├── hurl.md
│   │   │           │   ├── introduction.md
│   │   │           │   ├── postman.md
│   │   │           │   └── tests.md
│   │   │           ├── frontend/
│   │   │           │   ├── api.md
│   │   │           │   ├── routing.md
│   │   │           │   ├── styles.md
│   │   │           │   ├── templates.md
│   │   │           │   └── tests.md
│   │   │           └── mobile-specs/
│   │   │               └── introduction.md
│   │   ├── env.d.ts
│   │   └── tailwind.css
│   └── tsconfig.json
└── specs/
    ├── api/
    │   ├── README.md
    │   ├── bruno/
    │   │   ├── articles/
    │   │   │   ├── 01-setup-register.bru
    │   │   │   ├── 02-create-article-with-tags.bru
    │   │   │   ├── 03-list-all-articles.bru
    │   │   │   ├── 04-list-by-author.bru
    │   │   │   ├── 05-list-all-articles-with-auth.bru
    │   │   │   ├── 06-list-by-author-with-auth.bru
    │   │   │   ├── 07-list-by-tag.bru
    │   │   │   ├── 08-list-articles-without-auth.bru
    │   │   │   ├── 09-get-single-article.bru
    │   │   │   ├── 10-update-article-body.bru
    │   │   │   ├── 11-verify-update-persisted.bru
    │   │   │   ├── 12-update-article-without-taglist-tags-should-be-preserved.bru
    │   │   │   ├── 13-update-article-remove-all-tags-with-empty-array.bru
    │   │   │   ├── 14-verify-tags-were-actually-removed.bru
    │   │   │   ├── 15-update-article-taglist-null-should-be-rejected.bru
    │   │   │   ├── 16-delete-article.bru
    │   │   │   └── 17-verify-deletion.bru
    │   │   ├── auth/
    │   │   │   ├── 01-register.bru
    │   │   │   ├── 02-login.bru
    │   │   │   ├── 03-get-current-user.bru
    │   │   │   ├── 04-update-user.bru
    │   │   │   ├── 05-verify-update-persisted.bru
    │   │   │   ├── 06-update-user-bio-to-empty-string-should-normalize-to-null.bru
    │   │   │   ├── 07-verify-empty-string-normalization-persisted.bru
    │   │   │   ├── 08-restore-bio-then-set-to-null.bru
    │   │   │   ├── 09-update-user-bio-to-null-should-accept-for-nullable-field.bru
    │   │   │   ├── 10-verify-null-bio-persisted.bru
    │   │   │   ├── 11-restore-bio.bru
    │   │   │   ├── 12-update-user-image.bru
    │   │   │   ├── 13-verify-image-update-persisted.bru
    │   │   │   ├── 14-update-image-to-empty-string-should-normalize-to-null.bru
    │   │   │   ├── 15-verify-image-empty-string-normalization-persisted.bru
    │   │   │   ├── 16-set-image-then-update-to-null-should-accept-for-nullable-field.bru
    │   │   │   ├── 17-put-user.bru
    │   │   │   ├── 18-verify-null-image-persisted.bru
    │   │   │   ├── 19-update-username-and-email.bru
    │   │   │   └── 20-verify-username-email-update-persisted.bru
    │   │   ├── bruno.json
    │   │   ├── collection.bru
    │   │   ├── comments/
    │   │   │   ├── 01-setup-register.bru
    │   │   │   ├── 02-setup-create-article.bru
    │   │   │   ├── 03-create-comment.bru
    │   │   │   ├── 04-list-comments.bru
    │   │   │   ├── 05-list-comments-without-auth.bru
    │   │   │   ├── 06-delete-comment.bru
    │   │   │   ├── 07-verify-deletion.bru
    │   │   │   ├── 08-selective-deletion-create-two-comments-delete-one-verify-the-other-remains.bru
    │   │   │   ├── 09-post-comments.bru
    │   │   │   ├── 10-verify-two-comments-exist.bru
    │   │   │   ├── 11-delete-the-first-comment.bru
    │   │   │   ├── 12-verify-only-the-second-comment-remains.bru
    │   │   │   └── 13-cleanup.bru
    │   │   ├── environments/
    │   │   │   └── local.bru
    │   │   ├── errors-articles/
    │   │   │   ├── 01-create-article-no-auth.bru
    │   │   │   ├── 02-get-unknown-slug.bru
    │   │   │   ├── 03-update-no-auth.bru
    │   │   │   ├── 04-delete-no-auth.bru
    │   │   │   ├── 05-get-feed-no-auth.bru
    │   │   │   ├── 06-favorite-no-auth.bru
    │   │   │   ├── 07-unfavorite-no-auth.bru
    │   │   │   ├── 08-setup-register-for-authenticated-error-tests.bru
    │   │   │   ├── 09-create-article-empty-title.bru
    │   │   │   ├── 10-create-article-empty-description.bru
    │   │   │   ├── 11-create-article-empty-body.bru
    │   │   │   ├── 12-duplicate-titles-are-allowed-each-gets-a-unique-slug.bru
    │   │   │   ├── 13-post-articles.bru
    │   │   │   ├── 14-update-unknown-slug.bru
    │   │   │   ├── 15-favorite-unknown-slug.bru
    │   │   │   ├── 16-unfavorite-unknown-slug.bru
    │   │   │   ├── 17-update-unknown-slug.bru
    │   │   │   ├── 18-delete-unknown-slug.bru
    │   │   │   ├── 19-cleanup.bru
    │   │   │   └── 20-delete-slug2.bru
    │   │   ├── errors-auth/
    │   │   │   ├── 01-register-empty-username.bru
    │   │   │   ├── 02-register-empty-email.bru
    │   │   │   ├── 03-register-empty-password.bru
    │   │   │   ├── 04-register-valid-user-for-duplicate-and-login-tests.bru
    │   │   │   ├── 05-register-duplicate-username.bru
    │   │   │   ├── 06-register-duplicate-email.bru
    │   │   │   ├── 07-login-empty-email.bru
    │   │   │   ├── 08-login-empty-password.bru
    │   │   │   ├── 09-login-wrong-password.bru
    │   │   │   ├── 10-get-user-no-auth.bru
    │   │   │   ├── 11-put-user-no-auth.bru
    │   │   │   ├── 12-update-email-to-empty-string-should-reject.bru
    │   │   │   ├── 13-update-username-to-empty-string-should-reject.bru
    │   │   │   ├── 14-update-email-to-null-should-reject.bru
    │   │   │   └── 15-update-username-to-null-should-reject.bru
    │   │   ├── errors-authorization/
    │   │   │   ├── 01-register-user-a.bru
    │   │   │   ├── 02-register-user-b.bru
    │   │   │   ├── 03-user-a-creates-article.bru
    │   │   │   ├── 04-user-b-tries-to-delete-403.bru
    │   │   │   ├── 05-user-b-tries-to-update-403.bru
    │   │   │   ├── 06-user-a-creates-a-comment-on-the-article.bru
    │   │   │   ├── 07-user-b-tries-to-delete-a-s-comment-403.bru
    │   │   │   ├── 08-verify-comment-survived-the-failed-delete.bru
    │   │   │   └── 09-cleanup-user-a-deletes-article.bru
    │   │   ├── errors-comments/
    │   │   │   ├── 01-post-comment-no-auth.bru
    │   │   │   ├── 02-delete-comment-no-auth.bru
    │   │   │   ├── 03-setup-register-create-article.bru
    │   │   │   ├── 04-post-articles.bru
    │   │   │   ├── 05-post-comment-empty-body.bru
    │   │   │   ├── 06-post-comment-on-unknown-article.bru
    │   │   │   ├── 07-get-comments-on-unknown-article.bru
    │   │   │   ├── 08-delete-comment-on-unknown-article.bru
    │   │   │   ├── 09-delete-non-existent-comment-on-existing-article.bru
    │   │   │   └── 10-cleanup.bru
    │   │   ├── errors-profiles/
    │   │   │   ├── 01-get-unknown-profile.bru
    │   │   │   ├── 02-follow-no-auth.bru
    │   │   │   ├── 03-unfollow-no-auth.bru
    │   │   │   ├── 04-setup-register-for-authenticated-404-tests.bru
    │   │   │   ├── 05-follow-unknown-user-authed.bru
    │   │   │   └── 06-unfollow-unknown-user-authed.bru
    │   │   ├── favorites/
    │   │   │   ├── 01-setup-register.bru
    │   │   │   ├── 02-setup-create-article.bru
    │   │   │   ├── 03-favorite-article.bru
    │   │   │   ├── 04-verify-favorite-persists.bru
    │   │   │   ├── 05-articles-filtered-by-favorited-username.bru
    │   │   │   ├── 06-articles-filtered-by-favorited-username-with-auth.bru
    │   │   │   ├── 07-unfavorite-article.bru
    │   │   │   ├── 08-verify-unfavorite-persists.bru
    │   │   │   └── 09-cleanup.bru
    │   │   ├── feed/
    │   │   │   ├── 01-register-main-user.bru
    │   │   │   ├── 02-register-celeb-user.bru
    │   │   │   ├── 03-feed-for-new-user-returns-empty.bru
    │   │   │   ├── 04-main-follows-celeb.bru
    │   │   │   ├── 05-celeb-creates-article-1.bru
    │   │   │   ├── 06-celeb-creates-article-2.bru
    │   │   │   ├── 07-main-checks-feed.bru
    │   │   │   ├── 08-feed-with-limit-1.bru
    │   │   │   ├── 09-feed-with-limit-1-offset-1.bru
    │   │   │   ├── 10-cleanup-delete-articles.bru
    │   │   │   ├── 11-delete-slug2.bru
    │   │   │   └── 12-cleanup-unfollow.bru
    │   │   ├── pagination/
    │   │   │   ├── 01-setup-register.bru
    │   │   │   ├── 02-create-article-1.bru
    │   │   │   ├── 03-create-article-2.bru
    │   │   │   ├── 04-list-with-limit-1-most-recent-first-so-slug2.bru
    │   │   │   ├── 05-list-with-limit-1-offset-1-second-page-so-slug1.bru
    │   │   │   ├── 06-cleanup.bru
    │   │   │   └── 07-delete-slug2.bru
    │   │   ├── profiles/
    │   │   │   ├── 01-register-main-user.bru
    │   │   │   ├── 02-register-celeb-user.bru
    │   │   │   ├── 03-get-profile-without-auth.bru
    │   │   │   ├── 04-get-profile-with-auth.bru
    │   │   │   ├── 05-follow-profile.bru
    │   │   │   ├── 06-unfollow-profile.bru
    │   │   │   └── 07-verify-unfollow-persisted.bru
    │   │   └── tags/
    │   │       ├── 01-setup-register.bru
    │   │       ├── 02-setup-create-article-with-tags.bru
    │   │       ├── 03-get-tags.bru
    │   │       └── 04-cleanup.bru
    │   ├── hurl/
    │   │   ├── articles.hurl
    │   │   ├── auth.hurl
    │   │   ├── comments.hurl
    │   │   ├── errors_articles.hurl
    │   │   ├── errors_auth.hurl
    │   │   ├── errors_authorization.hurl
    │   │   ├── errors_comments.hurl
    │   │   ├── errors_profiles.hurl
    │   │   ├── favorites.hurl
    │   │   ├── feed.hurl
    │   │   ├── pagination.hurl
    │   │   ├── profiles.hurl
    │   │   ├── run-hurl-tests.sh
    │   │   └── tags.hurl
    │   ├── hurl-to-bruno.js
    │   ├── openapi.yml
    │   ├── run-api-tests-bruno.sh
    │   └── run-api-tests-hurl.sh
    └── e2e/
        ├── SELECTORS.md
        ├── articles.spec.ts
        ├── auth.spec.ts
        ├── comments.spec.ts
        ├── error-handling.spec.ts
        ├── health.spec.ts
        ├── helpers/
        │   ├── api.ts
        │   ├── articles.ts
        │   ├── auth.ts
        │   ├── comments.ts
        │   ├── config.ts
        │   ├── debug.ts
        │   ├── profile.ts
        │   └── setup.ts
        ├── navigation.spec.ts
        ├── null-fields.spec.ts
        ├── playwright.base.ts
        ├── settings.spec.ts
        ├── social.spec.ts
        ├── url-navigation.spec.ts
        ├── user-fetch-errors.spec.ts
        └── xss-security.spec.ts
Download .txt
SYMBOL INDEX (63 symbols across 14 files)

FILE: assets/media/conduit-logo.svg.generate.ts
  constant FONT_URL (line 6) | const FONT_URL = 'https://fonts.gstatic.com/s/caudex/v19/esDT311QOP6BJUr...
  constant TMP_DIR (line 7) | const TMP_DIR = '.tmp';
  constant FONT_PATH (line 8) | const FONT_PATH = `${TMP_DIR}/caudex-bold.ttf`;

FILE: docs/astro.config.mjs
  function removeMdExtension (line 42) | function removeMdExtension() {

FILE: specs/api/hurl-to-bruno.js
  constant ROOT (line 9) | const ROOT = resolve(import.meta.dirname);
  constant HURL_DIR (line 10) | const HURL_DIR = join(ROOT, "hurl");
  constant BRUNO_DIR (line 11) | const BRUNO_DIR = join(ROOT, "bruno");
  constant CHECK_MODE (line 12) | const CHECK_MODE = process.argv.includes("--check");
  function parseHurlFile (line 16) | function parseHurlFile(filePath) {
  function slugify (line 177) | function slugify(str) {
  function folderName (line 184) | function folderName(filename) {
  function fileName (line 188) | function fileName(request, index, method, url) {
  function jsonpathToJs (line 201) | function jsonpathToJs(jp) {
  function jsonpathToPropertyCheck (line 210) | function jsonpathToPropertyCheck(jp) {
  function transformValue (line 227) | function transformValue(rawValue) {
  function assertToJs (line 284) | function assertToJs(assertLine) {
  function generateBruFile (line 405) | function generateBruFile(request, seq) {
  function generateCollection (line 467) | function generateCollection(outputDir) {
  function collectFiles (line 529) | function collectFiles(dir, prefix = "") {
  function checkMode (line 545) | function checkMode() {

FILE: specs/e2e/error-handling.spec.ts
  constant API_BASE (line 8) | const API_BASE = 'https://api.realworld.show/api';
  function mockApiError (line 13) | async function mockApiError(page: Page, endpoint: string, status: number...
  function setFakeAuthToken (line 29) | async function setFakeAuthToken(page: Page) {

FILE: specs/e2e/helpers/api.ts
  type UserCredentials (line 4) | interface UserCredentials {
  function registerUserViaAPI (line 10) | async function registerUserViaAPI(request: APIRequestContext, user: User...
  function loginUserViaAPI (line 27) | async function loginUserViaAPI(request: APIRequestContext, email: string...
  function createArticleViaAPI (line 43) | async function createArticleViaAPI(
  function updateUserViaAPI (line 68) | async function updateUserViaAPI(
  function createManyArticles (line 86) | async function createManyArticles(

FILE: specs/e2e/helpers/articles.ts
  type ArticleData (line 3) | interface ArticleData {
  function createArticle (line 10) | async function createArticle(page: Page, article: ArticleData, options: ...
  function editArticle (line 35) | async function editArticle(page: Page, slug: string, updates: Partial<Ar...
  function deleteArticle (line 58) | async function deleteArticle(page: Page) {
  function favoriteArticle (line 63) | async function favoriteArticle(page: Page) {
  function unfavoriteArticle (line 69) | async function unfavoriteArticle(page: Page) {
  function generateUniqueArticle (line 75) | function generateUniqueArticle(): ArticleData {

FILE: specs/e2e/helpers/auth.ts
  function register (line 3) | async function register(page: Page, username: string, email: string, pas...
  function login (line 25) | async function login(page: Page, email: string, password: string) {
  function logout (line 46) | async function logout(page: Page) {
  function generateUniqueUser (line 51) | function generateUniqueUser() {

FILE: specs/e2e/helpers/comments.ts
  function addComment (line 3) | async function addComment(page: Page, commentText: string) {
  function deleteComment (line 21) | async function deleteComment(page: Page, commentText: string) {
  function getCommentCount (line 29) | async function getCommentCount(page: Page): Promise<number> {

FILE: specs/e2e/helpers/config.ts
  constant API_MODE (line 1) | const API_MODE = process.env.API_MODE?.toLowerCase() !== 'false';
  constant API_BASE (line 2) | const API_BASE = process.env.API_BASE || 'https://api.realworld.show/api';

FILE: specs/e2e/helpers/debug.ts
  type AuthState (line 22) | type AuthState = 'authenticated' | 'unauthenticated' | 'unavailable' | '...
  type User (line 24) | interface User {
  function getToken (line 36) | async function getToken(page: Page): Promise<string | null> {
  function getAuthState (line 44) | async function getAuthState(page: Page): Promise<AuthState | undefined> {
  function getCurrentUser (line 52) | async function getCurrentUser(page: Page): Promise<User | null> {
  function waitForAuthState (line 60) | async function waitForAuthState(
  function isDebugInterfaceAvailable (line 73) | async function isDebugInterfaceAvailable(page: Page): Promise<boolean> {

FILE: specs/e2e/helpers/profile.ts
  function followUser (line 4) | async function followUser(page: Page, username: string) {
  function unfollowUser (line 13) | async function unfollowUser(page: Page, username: string) {
  function updateProfile (line 22) | async function updateProfile(

FILE: specs/e2e/helpers/setup.ts
  type UserCredentials (line 6) | interface UserCredentials {
  function createUserInIsolation (line 16) | async function createUserInIsolation(
  function createManyArticles (line 32) | async function createManyArticles(

FILE: specs/e2e/user-fetch-errors.spec.ts
  constant API_BASE (line 9) | const API_BASE = 'https://api.realworld.show/api';

FILE: specs/e2e/xss-security.spec.ts
  constant XSS_IMAGE_PAYLOADS (line 43) | const XSS_IMAGE_PAYLOADS = [
  constant XSS_MARKDOWN_PAYLOADS (line 67) | const XSS_MARKDOWN_PAYLOADS = [
  function setupXssDetector (line 98) | function setupXssDetector(page: Page): () => boolean {
  function injectToken (line 110) | async function injectToken(page: Page, token: string): Promise<void> {
Condensed preview — 245 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (469K chars).
[
  {
    "path": ".github/CODEOWNERS",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/BUG_REPORT.yml",
    "chars": 710,
    "preview": "name: 🐞 Bug report\ndescription: Report a bug in the RealWorld project\ntitle: '[Bug]: '\nlabels:\n  - bug\nbody:\n  - type: d"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml",
    "chars": 1045,
    "preview": "name: 🚀 Feature request\ndescription: Suggest a feature for RealWorld project\ntitle: '[Feature Request]:'\nbody:\n  - type:"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 264,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: github-actions\n    directory: '/'\n    schedule:\n      interval: weekly\n    op"
  },
  {
    "path": ".github/workflows/bruno-check.yml",
    "chars": 368,
    "preview": "name: 'Bruno Check'\n\non:\n  push:\n  pull_request:\n\njobs:\n  bruno-check:\n    name: Verify Bruno collection is up-to-date\n "
  },
  {
    "path": ".github/workflows/codeql.yml",
    "chars": 679,
    "preview": "name: 'CodeQL'\n\non:\n  workflow_dispatch:\n  schedule:\n    - cron: '24 3 * * 3'\n\njobs:\n  analyze:\n    name: Analyze\n    ru"
  },
  {
    "path": ".github/workflows/deploy-docs.yml",
    "chars": 1154,
    "preview": "name: Deploy Documentation\n\non:\n  push:\n    branches: [main]\n    paths:\n      - 'docs/**'\n      - '.github/workflows/dep"
  },
  {
    "path": ".github/workflows/spammy-guardian.yml",
    "chars": 481,
    "preview": "name: Spammy Guardian\non:\n  workflow_dispatch:\n    inputs:\n      issueId:\n        description: 'id of the issue to test "
  },
  {
    "path": ".gitignore",
    "chars": 482,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# Dependencies\nnode_modules\n.pnp\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 7118,
    "preview": "# Contributing to RealWorld\n\nWe would love for you to contribute to RealWorld and help make it even better than it is\nto"
  },
  {
    "path": "LICENSE",
    "chars": 1284,
    "preview": "MIT License\n\nCopyright (c) 2021 Thinkster\nCopyright (c) 2026 c4ffein\n\nPermission is hereby granted, free of charge, to a"
  },
  {
    "path": "Makefile",
    "chars": 999,
    "preview": ".PHONY: help \\\n\tbruno-generate \\\n\tbruno-check \\\n\tdocumentation-setup \\\n\tdocumentation-dev \\\n\tdocumentation-dev-host \\\n\td"
  },
  {
    "path": "README.md",
    "chars": 3191,
    "preview": "![RealWorld Example Applications](assets/media/realworld-dual-mode.png)\n\n<p align=\"center\" style=\"margin-top: 30px;\">\n  "
  },
  {
    "path": "assets/media/conduit-logo.svg.generate.ts",
    "chars": 1174,
    "preview": "import opentype from 'opentype.js';\nimport { mkdirSync } from 'fs';\n\nprocess.chdir(import.meta.dir);\n\nconst FONT_URL = '"
  },
  {
    "path": "assets/media/mobile_icons/ios/AppIcon.appiconset/Contents.json",
    "chars": 3540,
    "preview": "{\n  \"images\": [\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"20x20\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-2"
  },
  {
    "path": "assets/media/mobile_icons/ios/README.md",
    "chars": 999,
    "preview": "## iTunesArtwork & iTunesArtwork@2x (App Icon) file extension:\n\nPNG extension is prepended to these two files -\n\nWhile A"
  },
  {
    "path": "assets/media/mobile_icons/watchkit/AppIcon.appiconset/Contents.json",
    "chars": 1419,
    "preview": "{\n  \"images\": [\n    {\n      \"size\": \"24x24\",\n      \"idiom\": \"watch\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-24@2x."
  },
  {
    "path": "assets/theme/styles.css",
    "chars": 28850,
    "preview": "/*\n * ============================================================================\n * Conduit Minimal CSS v4\n * Only inc"
  },
  {
    "path": "docs/.gitignore",
    "chars": 229,
    "preview": "# build output\ndist/\n# generated types\n.astro/\n\n# dependencies\nnode_modules/\n\n# logs\nnpm-debug.log*\nyarn-debug.log*\nyarn"
  },
  {
    "path": "docs/.vscode/extensions.json",
    "chars": 87,
    "preview": "{\n  \"recommendations\": [\"astro-build.astro-vscode\"],\n  \"unwantedRecommendations\": []\n}\n"
  },
  {
    "path": "docs/.vscode/launch.json",
    "chars": 207,
    "preview": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"command\": \"./node_modules/.bin/astro dev\",\n      \"name\": \"Dev"
  },
  {
    "path": "docs/README.md",
    "chars": 2564,
    "preview": "# Starlight Starter Kit: Basics\n\n[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https"
  },
  {
    "path": "docs/astro.config.mjs",
    "chars": 6784,
    "preview": "import {defineConfig} from 'astro/config';\nimport starlight from '@astrojs/starlight';\nimport tailwindcss from \"@tailwin"
  },
  {
    "path": "docs/non-included/LICENSES_LOGOS.md",
    "chars": 5610,
    "preview": "# Logo Licenses & Trademark Analysis\n\n> **Disclaimer**: This document was generated by Claude (Anthropic) and is provide"
  },
  {
    "path": "docs/package.json",
    "chars": 669,
    "preview": "{\n  \"name\": \"documentation\",\n  \"type\": \"module\",\n  \"version\": \"0.0.1\",\n  \"scripts\": {\n    \"dev\": \"astro dev\",\n    \"start"
  },
  {
    "path": "docs/src/content/config.ts",
    "chars": 190,
    "preview": "import { defineCollection } from 'astro:content';\nimport { docsSchema } from '@astrojs/starlight/schema';\n\nexport const "
  },
  {
    "path": "docs/src/content/docs/community/authors.md",
    "chars": 3386,
    "preview": "---\ntitle: Authors\n---\n\n# Who currently maintains the project\n\n#### [Gérôme Grignon](https://github.com/geromegrignon) -"
  },
  {
    "path": "docs/src/content/docs/community/resources.md",
    "chars": 1041,
    "preview": "---\ntitle: Resources\n---\n\n# Community created resources\n\n- Performance comparisons:\n  - [A Real-World Comparison of Fron"
  },
  {
    "path": "docs/src/content/docs/community/special-thanks.md",
    "chars": 1255,
    "preview": "---\ntitle: Special thanks\n---\n\nRealWorld would not be possible without the open source community's assistance in reviewi"
  },
  {
    "path": "docs/src/content/docs/implementation-creation/expectations.md",
    "chars": 2804,
    "preview": "---\ntitle: Expectations\n---\n\n## Remember: Keep your codebases _simple_, yet _robust_.\n\nIf a new developer to your framew"
  },
  {
    "path": "docs/src/content/docs/implementation-creation/features.md",
    "chars": 352,
    "preview": "---\ntitle: Features\n---\n\n**General functionality:**\n\n- Authenticate users via JWT (login/signup pages + logout button on"
  },
  {
    "path": "docs/src/content/docs/implementation-creation/introduction.md",
    "chars": 757,
    "preview": "---\ntitle: Introduction\n---\n\n**Conduit** is a social blogging site (i.e. a Medium.com clone). It uses a custom API for a"
  },
  {
    "path": "docs/src/content/docs/index.mdx",
    "chars": 612,
    "preview": "---\ntitle: RealWorld apps\ndescription: It's all about building real world, production ready apps.\ntemplate: splash\nhero:"
  },
  {
    "path": "docs/src/content/docs/introduction.mdx",
    "chars": 2098,
    "preview": "---\ntitle: Introduction\n---\n\n# Introduction\n\n> See how _the exact same_ Medium.com clone is built using different [front"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/api-response-format.md",
    "chars": 3148,
    "preview": "---\ntitle: API response format\n---\n\n## JSON Objects returned by API:\n\nMake sure the right content type like `Content-Typ"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/bruno.md",
    "chars": 812,
    "preview": "---\ntitle: Bruno\n---\n\nFor your convenience, we have a [Bruno collection](https://github.com/realworld-apps/realworld/tre"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/cors.md",
    "chars": 346,
    "preview": "---\ntitle: CORS\n---\n\n## Considerations for your backend with [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/endpoints.md",
    "chars": 5048,
    "preview": "---\ntitle: Endpoints\n---\n\n### Authentication Header:\n\nYou can read the authentication header from the headers of the req"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/error-handling.md",
    "chars": 541,
    "preview": "---\ntitle: Error handling\n---\n\n### Errors and Status Codes\n\nIf a request fails any validations, expect a 422 and errors "
  },
  {
    "path": "docs/src/content/docs/specifications/backend/hurl.md",
    "chars": 645,
    "preview": "---\ntitle: Hurl\n---\n\nFor your convenience, we have a [Hurl collection](https://github.com/realworld-apps/realworld/tree/"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/introduction.md",
    "chars": 852,
    "preview": "---\ntitle: Introduction\n---\n\nAll backend implementations need to adhere to our [API spec](https://github.com/realworld-a"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/postman.md",
    "chars": 441,
    "preview": "---\ntitle: Postman\n---\n\nFor your convenience, we have a [Postman collection](https://github.com/realworld-apps/realworld"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/tests.md",
    "chars": 148,
    "preview": "---\ntitle: Tests\n---\n\nInclude _at least_ **one** unit test in your repo to demonstrate how testing works (full testing c"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/api.md",
    "chars": 1294,
    "preview": "---\ntitle: API\n---\n\nThis project provides you different solutions to test your frontend implementation with an API by:\n\n"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/routing.md",
    "chars": 900,
    "preview": "---\ntitle: Routing\n---\n\n- Home page (URL: `/` )\n  - List of tags\n  - List of articles pulled from either Feed, Global, o"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/styles.md",
    "chars": 733,
    "preview": "---\ntitle: Styles\n---\n\nAll frontend implementations should use the shared [styles.css](https://github.com/realworld-apps"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/templates.md",
    "chars": 19532,
    "preview": "---\ntitle: Templates\n---\n\n## Head\n\nThe `<head>` element includes all the metadata for a page, including the title, descr"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/tests.md",
    "chars": 1330,
    "preview": "---\ntitle: Tests\n---\n\nInclude _at least_ **one** unit test in your repo to demonstrate how testing works (full testing c"
  },
  {
    "path": "docs/src/content/docs/specifications/mobile-specs/introduction.md",
    "chars": 690,
    "preview": "---\ntitle: Introduction\n---\n\n### [Icons for (iOS/Android)](https://github.com/realworld-apps/realworld/tree/master/spec/"
  },
  {
    "path": "docs/src/env.d.ts",
    "chars": 85,
    "preview": "/// <reference path=\"../.astro/types.d.ts\" />\n/// <reference types=\"astro/client\" />\n"
  },
  {
    "path": "docs/src/tailwind.css",
    "chars": 942,
    "preview": "@import \"tailwindcss\";\n@import \"@astrojs/starlight-tailwind\";\n@source \"../**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,v"
  },
  {
    "path": "docs/tsconfig.json",
    "chars": 124,
    "preview": "{\n  \"extends\": \"astro/tsconfigs/strict\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"react\"\n "
  },
  {
    "path": "specs/api/README.md",
    "chars": 851,
    "preview": "# RealWorld API Spec\n\n## Running API tests locally\n\n### With Hurl\n\nTo locally run the provided [Hurl](https://hurl.dev) "
  },
  {
    "path": "specs/api/bruno/articles/01-setup-register.bru",
    "chars": 367,
    "preview": "meta {\n  name: Setup: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbo"
  },
  {
    "path": "specs/api/bruno/articles/02-create-article-with-tags.bru",
    "chars": 1479,
    "preview": "meta {\n  name: Create article with tags\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth"
  },
  {
    "path": "specs/api/bruno/articles/03-list-all-articles.bru",
    "chars": 1085,
    "preview": "meta {\n  name: List all articles\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{host}}/api/articles\n  body: none\n  auth: none\n}"
  },
  {
    "path": "specs/api/bruno/articles/04-list-by-author.bru",
    "chars": 1112,
    "preview": "meta {\n  name: List by author\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/articles?author=art_{{uid}}\n  body: non"
  },
  {
    "path": "specs/api/bruno/articles/05-list-all-articles-with-auth.bru",
    "chars": 1141,
    "preview": "meta {\n  name: List all articles with auth\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/articles\n  body: none\n  au"
  },
  {
    "path": "specs/api/bruno/articles/06-list-by-author-with-auth.bru",
    "chars": 1168,
    "preview": "meta {\n  name: List by author with auth\n  type: http\n  seq: 6\n}\n\nget {\n  url: {{host}}/api/articles?author=art_{{uid}}\n "
  },
  {
    "path": "specs/api/bruno/articles/07-list-by-tag.bru",
    "chars": 1170,
    "preview": "meta {\n  name: List by tag\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/articles?tag=d_{{uid}}\n  body: none\n  auth"
  },
  {
    "path": "specs/api/bruno/articles/08-list-articles-without-auth.bru",
    "chars": 311,
    "preview": "meta {\n  name: List articles without auth\n  type: http\n  seq: 8\n}\n\nget {\n  url: {{host}}/api/articles\n  body: none\n  aut"
  },
  {
    "path": "specs/api/bruno/articles/09-get-single-article.bru",
    "chars": 890,
    "preview": "meta {\n  name: Get single article\n  type: http\n  seq: 9\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  au"
  },
  {
    "path": "specs/api/bruno/articles/10-update-article-body.bru",
    "chars": 1215,
    "preview": "meta {\n  name: Update article body\n  type: http\n  seq: 10\n}\n\nput {\n  url: {{host}}/api/articles/{{slug}}\n  body: json\n  "
  },
  {
    "path": "specs/api/bruno/articles/11-verify-update-persisted.bru",
    "chars": 1130,
    "preview": "meta {\n  name: Verify update persisted\n  type: http\n  seq: 11\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: non"
  },
  {
    "path": "specs/api/bruno/articles/12-update-article-without-taglist-tags-should-be-preserved.bru",
    "chars": 633,
    "preview": "meta {\n  name: Update article without tagList: tags should be preserved\n  type: http\n  seq: 12\n}\n\nput {\n  url: {{host}}/"
  },
  {
    "path": "specs/api/bruno/articles/13-update-article-remove-all-tags-with-empty-array.bru",
    "chars": 450,
    "preview": "meta {\n  name: Update article: remove all tags with empty array\n  type: http\n  seq: 13\n}\n\nput {\n  url: {{host}}/api/arti"
  },
  {
    "path": "specs/api/bruno/articles/14-verify-tags-were-actually-removed.bru",
    "chars": 323,
    "preview": "meta {\n  name: Verify tags were actually removed\n  type: http\n  seq: 14\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n "
  },
  {
    "path": "specs/api/bruno/articles/15-update-article-taglist-null-should-be-rejected.bru",
    "chars": 308,
    "preview": "meta {\n  name: Update article: tagList null should be rejected\n  type: http\n  seq: 15\n}\n\nput {\n  url: {{host}}/api/artic"
  },
  {
    "path": "specs/api/bruno/articles/16-delete-article.bru",
    "chars": 210,
    "preview": "meta {\n  name: Delete article\n  type: http\n  seq: 16\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  au"
  },
  {
    "path": "specs/api/bruno/articles/17-verify-deletion.bru",
    "chars": 246,
    "preview": "meta {\n  name: Verify deletion\n  type: http\n  seq: 17\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth"
  },
  {
    "path": "specs/api/bruno/auth/01-register.bru",
    "chars": 700,
    "preview": "meta {\n  name: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json"
  },
  {
    "path": "specs/api/bruno/auth/02-login.bru",
    "chars": 665,
    "preview": "meta {\n  name: Login\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users/login\n  body: json\n  auth: none\n}\n\nbody:j"
  },
  {
    "path": "specs/api/bruno/auth/03-get-current-user.bru",
    "chars": 555,
    "preview": "meta {\n  name: Get current user\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nhea"
  },
  {
    "path": "specs/api/bruno/auth/04-update-user.bru",
    "chars": 631,
    "preview": "meta {\n  name: Update user\n  type: http\n  seq: 4\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders "
  },
  {
    "path": "specs/api/bruno/auth/05-verify-update-persisted.bru",
    "chars": 573,
    "preview": "meta {\n  name: Verify update persisted\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none"
  },
  {
    "path": "specs/api/bruno/auth/06-update-user-bio-to-empty-string-should-normalize-to-null.bru",
    "chars": 362,
    "preview": "meta {\n  name: Update user bio to empty string - should normalize to null\n  type: http\n  seq: 6\n}\n\nput {\n  url: {{host}}"
  },
  {
    "path": "specs/api/bruno/auth/07-verify-empty-string-normalization-persisted.bru",
    "chars": 288,
    "preview": "meta {\n  name: Verify empty string normalization persisted\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/user\n  bod"
  },
  {
    "path": "specs/api/bruno/auth/08-restore-bio-then-set-to-null.bru",
    "chars": 358,
    "preview": "meta {\n  name: Restore bio then set to null\n  type: http\n  seq: 8\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth:"
  },
  {
    "path": "specs/api/bruno/auth/09-update-user-bio-to-null-should-accept-for-nullable-field.bru",
    "chars": 364,
    "preview": "meta {\n  name: Update user bio to null - should accept for nullable field\n  type: http\n  seq: 9\n}\n\nput {\n  url: {{host}}"
  },
  {
    "path": "specs/api/bruno/auth/10-verify-null-bio-persisted.bru",
    "chars": 271,
    "preview": "meta {\n  name: Verify null bio persisted\n  type: http\n  seq: 10\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: n"
  },
  {
    "path": "specs/api/bruno/auth/11-restore-bio.bru",
    "chars": 632,
    "preview": "meta {\n  name: Restore bio\n  type: http\n  seq: 11\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders"
  },
  {
    "path": "specs/api/bruno/auth/12-update-user-image.bru",
    "chars": 384,
    "preview": "meta {\n  name: Update user image\n  type: http\n  seq: 12\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nh"
  },
  {
    "path": "specs/api/bruno/auth/13-verify-image-update-persisted.bru",
    "chars": 306,
    "preview": "meta {\n  name: Verify image update persisted\n  type: http\n  seq: 13\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  aut"
  },
  {
    "path": "specs/api/bruno/auth/14-update-image-to-empty-string-should-normalize-to-null.bru",
    "chars": 364,
    "preview": "meta {\n  name: Update image to empty string - should normalize to null\n  type: http\n  seq: 14\n}\n\nput {\n  url: {{host}}/a"
  },
  {
    "path": "specs/api/bruno/auth/15-verify-image-empty-string-normalization-persisted.bru",
    "chars": 297,
    "preview": "meta {\n  name: Verify image empty string normalization persisted\n  type: http\n  seq: 15\n}\n\nget {\n  url: {{host}}/api/use"
  },
  {
    "path": "specs/api/bruno/auth/16-set-image-then-update-to-null-should-accept-for-nullable-field.bru",
    "chars": 429,
    "preview": "meta {\n  name: Set image then update to null - should accept for nullable field\n  type: http\n  seq: 16\n}\n\nput {\n  url: {"
  },
  {
    "path": "specs/api/bruno/auth/17-put-user.bru",
    "chars": 319,
    "preview": "meta {\n  name: PUT user\n  type: http\n  seq: 17\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n"
  },
  {
    "path": "specs/api/bruno/auth/18-verify-null-image-persisted.bru",
    "chars": 275,
    "preview": "meta {\n  name: Verify null image persisted\n  type: http\n  seq: 18\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth:"
  },
  {
    "path": "specs/api/bruno/auth/19-update-username-and-email.bru",
    "chars": 765,
    "preview": "meta {\n  name: Update username and email\n  type: http\n  seq: 19\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: n"
  },
  {
    "path": "specs/api/bruno/auth/20-verify-username-email-update-persisted.bru",
    "chars": 610,
    "preview": "meta {\n  name: Verify username/email update persisted\n  type: http\n  seq: 20\n}\n\nget {\n  url: {{host}}/api/user\n  body: n"
  },
  {
    "path": "specs/api/bruno/bruno.json",
    "chars": 72,
    "preview": "{\n  \"version\": \"1\",\n  \"name\": \"RealWorld API\",\n  \"type\": \"collection\"\n}\n"
  },
  {
    "path": "specs/api/bruno/collection.bru",
    "chars": 146,
    "preview": "script:pre-request {\n  if (!bru.getVar(\"uid\")) {\n    bru.setVar(\"uid\", Date.now().toString() + Math.random().toString(36"
  },
  {
    "path": "specs/api/bruno/comments/01-setup-register.bru",
    "chars": 367,
    "preview": "meta {\n  name: Setup: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbo"
  },
  {
    "path": "specs/api/bruno/comments/02-setup-create-article.bru",
    "chars": 430,
    "preview": "meta {\n  name: Setup: Create article\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: n"
  },
  {
    "path": "specs/api/bruno/comments/03-create-comment.bru",
    "chars": 759,
    "preview": "meta {\n  name: Create comment\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: jso"
  },
  {
    "path": "specs/api/bruno/comments/04-list-comments.bru",
    "chars": 753,
    "preview": "meta {\n  name: List comments\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: none\n"
  },
  {
    "path": "specs/api/bruno/comments/05-list-comments-without-auth.bru",
    "chars": 718,
    "preview": "meta {\n  name: List comments without auth\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n"
  },
  {
    "path": "specs/api/bruno/comments/06-delete-comment.bru",
    "chars": 233,
    "preview": "meta {\n  name: Delete comment\n  type: http\n  seq: 6\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}/comments/{{comment"
  },
  {
    "path": "specs/api/bruno/comments/07-verify-deletion.bru",
    "chars": 242,
    "preview": "meta {\n  name: Verify deletion\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: non"
  },
  {
    "path": "specs/api/bruno/comments/08-selective-deletion-create-two-comments-delete-one-verify-the-other-remains.bru",
    "chars": 436,
    "preview": "meta {\n  name: Selective deletion: create two comments, delete one, verify the other remains\n  type: http\n  seq: 8\n}\n\npo"
  },
  {
    "path": "specs/api/bruno/comments/09-post-comments.bru",
    "chars": 292,
    "preview": "meta {\n  name: POST comments\n  type: http\n  seq: 9\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: json"
  },
  {
    "path": "specs/api/bruno/comments/10-verify-two-comments-exist.bru",
    "chars": 253,
    "preview": "meta {\n  name: Verify two comments exist\n  type: http\n  seq: 10\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n"
  },
  {
    "path": "specs/api/bruno/comments/11-delete-the-first-comment.bru",
    "chars": 250,
    "preview": "meta {\n  name: Delete the first comment\n  type: http\n  seq: 11\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}/comment"
  },
  {
    "path": "specs/api/bruno/comments/12-verify-only-the-second-comment-remains.bru",
    "chars": 328,
    "preview": "meta {\n  name: Verify only the second comment remains\n  type: http\n  seq: 12\n}\n\nget {\n  url: {{host}}/api/articles/{{slu"
  },
  {
    "path": "specs/api/bruno/comments/13-cleanup.bru",
    "chars": 203,
    "preview": "meta {\n  name: Cleanup\n  type: http\n  seq: 13\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: non"
  },
  {
    "path": "specs/api/bruno/environments/local.bru",
    "chars": 39,
    "preview": "vars {\n  host: http://localhost:3000\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/01-create-article-no-auth.bru",
    "chars": 373,
    "preview": "meta {\n  name: Create article no auth\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: "
  },
  {
    "path": "specs/api/bruno/errors-articles/02-get-unknown-slug.bru",
    "chars": 258,
    "preview": "meta {\n  name: GET unknown slug\n  type: http\n  seq: 2\n}\n\nget {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}\n  body:"
  },
  {
    "path": "specs/api/bruno/errors-articles/03-update-no-auth.bru",
    "chars": 311,
    "preview": "meta {\n  name: Update no auth\n  type: http\n  seq: 3\n}\n\nput {\n  url: {{host}}/api/articles/some-slug\n  body: json\n  auth:"
  },
  {
    "path": "specs/api/bruno/errors-articles/04-delete-no-auth.bru",
    "chars": 247,
    "preview": "meta {\n  name: Delete no auth\n  type: http\n  seq: 4\n}\n\ndelete {\n  url: {{host}}/api/articles/some-slug\n  body: none\n  au"
  },
  {
    "path": "specs/api/bruno/errors-articles/05-get-feed-no-auth.bru",
    "chars": 241,
    "preview": "meta {\n  name: GET feed no auth\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/articles/feed\n  body: none\n  auth: no"
  },
  {
    "path": "specs/api/bruno/errors-articles/06-favorite-no-auth.bru",
    "chars": 256,
    "preview": "meta {\n  name: Favorite no auth\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/articles/some-slug/favorite\n  body: "
  },
  {
    "path": "specs/api/bruno/errors-articles/07-unfavorite-no-auth.bru",
    "chars": 260,
    "preview": "meta {\n  name: Unfavorite no auth\n  type: http\n  seq: 7\n}\n\ndelete {\n  url: {{host}}/api/articles/some-slug/favorite\n  bo"
  },
  {
    "path": "specs/api/bruno/errors-articles/08-setup-register-for-authenticated-error-tests.bru",
    "chars": 403,
    "preview": "meta {\n  name: Setup: Register for authenticated error tests\n  type: http\n  seq: 8\n}\n\npost {\n  url: {{host}}/api/users\n "
  },
  {
    "path": "specs/api/bruno/errors-articles/09-create-article-empty-title.bru",
    "chars": 412,
    "preview": "meta {\n  name: Create article empty title\n  type: http\n  seq: 9\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  au"
  },
  {
    "path": "specs/api/bruno/errors-articles/10-create-article-empty-description.bru",
    "chars": 437,
    "preview": "meta {\n  name: Create article empty description\n  type: http\n  seq: 10\n}\n\npost {\n  url: {{host}}/api/articles\n  body: js"
  },
  {
    "path": "specs/api/bruno/errors-articles/11-create-article-empty-body.bru",
    "chars": 423,
    "preview": "meta {\n  name: Create article empty body\n  type: http\n  seq: 11\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  au"
  },
  {
    "path": "specs/api/bruno/errors-articles/12-duplicate-titles-are-allowed-each-gets-a-unique-slug.bru",
    "chars": 445,
    "preview": "meta {\n  name: Duplicate titles are allowed (each gets a unique slug)\n  type: http\n  seq: 12\n}\n\npost {\n  url: {{host}}/a"
  },
  {
    "path": "specs/api/bruno/errors-articles/13-post-articles.bru",
    "chars": 471,
    "preview": "meta {\n  name: POST articles\n  type: http\n  seq: 13\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/14-update-unknown-slug.bru",
    "chars": 375,
    "preview": "meta {\n  name: Update unknown slug\n  type: http\n  seq: 14\n}\n\nput {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}\n  b"
  },
  {
    "path": "specs/api/bruno/errors-articles/15-favorite-unknown-slug.bru",
    "chars": 320,
    "preview": "meta {\n  name: Favorite unknown slug\n  type: http\n  seq: 15\n}\n\npost {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}/"
  },
  {
    "path": "specs/api/bruno/errors-articles/16-unfavorite-unknown-slug.bru",
    "chars": 324,
    "preview": "meta {\n  name: Unfavorite unknown slug\n  type: http\n  seq: 16\n}\n\ndelete {\n  url: {{host}}/api/articles/unknown-slug-{{ui"
  },
  {
    "path": "specs/api/bruno/errors-articles/17-update-unknown-slug.bru",
    "chars": 375,
    "preview": "meta {\n  name: Update unknown slug\n  type: http\n  seq: 17\n}\n\nput {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}\n  b"
  },
  {
    "path": "specs/api/bruno/errors-articles/18-delete-unknown-slug.bru",
    "chars": 311,
    "preview": "meta {\n  name: Delete unknown slug\n  type: http\n  seq: 18\n}\n\ndelete {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/19-cleanup.bru",
    "chars": 204,
    "preview": "meta {\n  name: Cleanup\n  type: http\n  seq: 19\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug1}}\n  body: none\n  auth: no"
  },
  {
    "path": "specs/api/bruno/errors-articles/20-delete-slug2.bru",
    "chars": 213,
    "preview": "meta {\n  name: DELETE {{slug2}}\n  type: http\n  seq: 20\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug2}}\n  body: none\n "
  },
  {
    "path": "specs/api/bruno/errors-auth/01-register-empty-username.bru",
    "chars": 389,
    "preview": "meta {\n  name: Register empty username\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: no"
  },
  {
    "path": "specs/api/bruno/errors-auth/02-register-empty-email.bru",
    "chars": 374,
    "preview": "meta {\n  name: Register empty email\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/03-register-empty-password.bru",
    "chars": 396,
    "preview": "meta {\n  name: Register empty password\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: no"
  },
  {
    "path": "specs/api/bruno/errors-auth/04-register-valid-user-for-duplicate-and-login-tests.bru",
    "chars": 407,
    "preview": "meta {\n  name: Register valid user for duplicate and login tests\n  type: http\n  seq: 4\n}\n\npost {\n  url: {{host}}/api/use"
  },
  {
    "path": "specs/api/bruno/errors-auth/05-register-duplicate-username.bru",
    "chars": 414,
    "preview": "meta {\n  name: Register duplicate username\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth"
  },
  {
    "path": "specs/api/bruno/errors-auth/06-register-duplicate-email.bru",
    "chars": 408,
    "preview": "meta {\n  name: Register duplicate email\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: n"
  },
  {
    "path": "specs/api/bruno/errors-auth/07-login-empty-email.bru",
    "chars": 339,
    "preview": "meta {\n  name: Login empty email\n  type: http\n  seq: 7\n}\n\npost {\n  url: {{host}}/api/users/login\n  body: json\n  auth: no"
  },
  {
    "path": "specs/api/bruno/errors-auth/08-login-empty-password.bru",
    "chars": 357,
    "preview": "meta {\n  name: Login empty password\n  type: http\n  seq: 8\n}\n\npost {\n  url: {{host}}/api/users/login\n  body: json\n  auth:"
  },
  {
    "path": "specs/api/bruno/errors-auth/09-login-wrong-password.bru",
    "chars": 366,
    "preview": "meta {\n  name: Login wrong password\n  type: http\n  seq: 9\n}\n\npost {\n  url: {{host}}/api/users/login\n  body: json\n  auth:"
  },
  {
    "path": "specs/api/bruno/errors-auth/10-get-user-no-auth.bru",
    "chars": 234,
    "preview": "meta {\n  name: GET /user no auth\n  type: http\n  seq: 10\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\na"
  },
  {
    "path": "specs/api/bruno/errors-auth/11-put-user-no-auth.bru",
    "chars": 297,
    "preview": "meta {\n  name: PUT /user no auth\n  type: http\n  seq: 11\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nb"
  },
  {
    "path": "specs/api/bruno/errors-auth/12-update-email-to-empty-string-should-reject.bru",
    "chars": 285,
    "preview": "meta {\n  name: Update email to empty string - should reject\n  type: http\n  seq: 12\n}\n\nput {\n  url: {{host}}/api/user\n  b"
  },
  {
    "path": "specs/api/bruno/errors-auth/13-update-username-to-empty-string-should-reject.bru",
    "chars": 291,
    "preview": "meta {\n  name: Update username to empty string - should reject\n  type: http\n  seq: 13\n}\n\nput {\n  url: {{host}}/api/user\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/14-update-email-to-null-should-reject.bru",
    "chars": 279,
    "preview": "meta {\n  name: Update email to null - should reject\n  type: http\n  seq: 14\n}\n\nput {\n  url: {{host}}/api/user\n  body: jso"
  },
  {
    "path": "specs/api/bruno/errors-auth/15-update-username-to-null-should-reject.bru",
    "chars": 285,
    "preview": "meta {\n  name: Update username to null - should reject\n  type: http\n  seq: 15\n}\n\nput {\n  url: {{host}}/api/user\n  body: "
  },
  {
    "path": "specs/api/bruno/errors-authorization/01-register-user-a.bru",
    "chars": 377,
    "preview": "meta {\n  name: Register user A\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbo"
  },
  {
    "path": "specs/api/bruno/errors-authorization/02-register-user-b.bru",
    "chars": 377,
    "preview": "meta {\n  name: Register user B\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbo"
  },
  {
    "path": "specs/api/bruno/errors-authorization/03-user-a-creates-article.bru",
    "chars": 415,
    "preview": "meta {\n  name: User A creates article\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: "
  },
  {
    "path": "specs/api/bruno/errors-authorization/04-user-b-tries-to-delete-403.bru",
    "chars": 310,
    "preview": "meta {\n  name: User B tries to delete -> 403\n  type: http\n  seq: 4\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  b"
  },
  {
    "path": "specs/api/bruno/errors-authorization/05-user-b-tries-to-update-403.bru",
    "chars": 378,
    "preview": "meta {\n  name: User B tries to update -> 403\n  type: http\n  seq: 5\n}\n\nput {\n  url: {{host}}/api/articles/{{slug}}\n  body"
  },
  {
    "path": "specs/api/bruno/errors-authorization/06-user-a-creates-a-comment-on-the-article.bru",
    "chars": 392,
    "preview": "meta {\n  name: User A creates a comment on the article\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/articles/{{sl"
  },
  {
    "path": "specs/api/bruno/errors-authorization/07-user-b-tries-to-delete-a-s-comment-403.bru",
    "chars": 346,
    "preview": "meta {\n  name: User B tries to delete A's comment -> 403\n  type: http\n  seq: 7\n}\n\ndelete {\n  url: {{host}}/api/articles/"
  },
  {
    "path": "specs/api/bruno/errors-authorization/08-verify-comment-survived-the-failed-delete.bru",
    "chars": 335,
    "preview": "meta {\n  name: Verify comment survived the failed delete\n  type: http\n  seq: 8\n}\n\nget {\n  url: {{host}}/api/articles/{{s"
  },
  {
    "path": "specs/api/bruno/errors-authorization/09-cleanup-user-a-deletes-article.bru",
    "chars": 228,
    "preview": "meta {\n  name: Cleanup: User A deletes article\n  type: http\n  seq: 9\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n "
  },
  {
    "path": "specs/api/bruno/errors-comments/01-post-comment-no-auth.bru",
    "chars": 327,
    "preview": "meta {\n  name: Post comment no auth\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/articles/some-slug/comments\n  bo"
  },
  {
    "path": "specs/api/bruno/errors-comments/02-delete-comment-no-auth.bru",
    "chars": 266,
    "preview": "meta {\n  name: Delete comment no auth\n  type: http\n  seq: 2\n}\n\ndelete {\n  url: {{host}}/api/articles/some-slug/comments/"
  },
  {
    "path": "specs/api/bruno/errors-comments/03-setup-register-create-article.bru",
    "chars": 382,
    "preview": "meta {\n  name: Setup: Register + create article\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n "
  },
  {
    "path": "specs/api/bruno/errors-comments/04-post-articles.bru",
    "chars": 406,
    "preview": "meta {\n  name: POST articles\n  type: http\n  seq: 4\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nh"
  },
  {
    "path": "specs/api/bruno/errors-comments/05-post-comment-empty-body.bru",
    "chars": 374,
    "preview": "meta {\n  name: Post comment empty body\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/comments\n  "
  },
  {
    "path": "specs/api/bruno/errors-comments/06-post-comment-on-unknown-article.bru",
    "chars": 398,
    "preview": "meta {\n  name: Post comment on unknown article\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/articles/unknown-slug"
  },
  {
    "path": "specs/api/bruno/errors-comments/07-get-comments-on-unknown-article.bru",
    "chars": 282,
    "preview": "meta {\n  name: Get comments on unknown article\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/articles/unknown-slug-"
  },
  {
    "path": "specs/api/bruno/errors-comments/08-delete-comment-on-unknown-article.bru",
    "chars": 339,
    "preview": "meta {\n  name: Delete comment on unknown article\n  type: http\n  seq: 8\n}\n\ndelete {\n  url: {{host}}/api/articles/unknown-"
  },
  {
    "path": "specs/api/bruno/errors-comments/09-delete-non-existent-comment-on-existing-article.bru",
    "chars": 341,
    "preview": "meta {\n  name: Delete non-existent comment on existing article\n  type: http\n  seq: 9\n}\n\ndelete {\n  url: {{host}}/api/art"
  },
  {
    "path": "specs/api/bruno/errors-comments/10-cleanup.bru",
    "chars": 203,
    "preview": "meta {\n  name: Cleanup\n  type: http\n  seq: 10\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: non"
  },
  {
    "path": "specs/api/bruno/errors-profiles/01-get-unknown-profile.bru",
    "chars": 261,
    "preview": "meta {\n  name: GET unknown profile\n  type: http\n  seq: 1\n}\n\nget {\n  url: {{host}}/api/profiles/unknown-user-{{uid}}\n  bo"
  },
  {
    "path": "specs/api/bruno/errors-profiles/02-follow-no-auth.bru",
    "chars": 263,
    "preview": "meta {\n  name: Follow no auth\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/profiles/unknown-user-{{uid}}/follow\n "
  },
  {
    "path": "specs/api/bruno/errors-profiles/03-unfollow-no-auth.bru",
    "chars": 267,
    "preview": "meta {\n  name: Unfollow no auth\n  type: http\n  seq: 3\n}\n\ndelete {\n  url: {{host}}/api/profiles/unknown-user-{{uid}}/foll"
  },
  {
    "path": "specs/api/bruno/errors-profiles/04-setup-register-for-authenticated-404-tests.bru",
    "chars": 393,
    "preview": "meta {\n  name: Setup: Register for authenticated 404 tests\n  type: http\n  seq: 4\n}\n\npost {\n  url: {{host}}/api/users\n  b"
  },
  {
    "path": "specs/api/bruno/errors-profiles/05-follow-unknown-user-authed.bru",
    "chars": 324,
    "preview": "meta {\n  name: Follow unknown user (authed)\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/profiles/unknown-user-{{"
  },
  {
    "path": "specs/api/bruno/errors-profiles/06-unfollow-unknown-user-authed.bru",
    "chars": 328,
    "preview": "meta {\n  name: Unfollow unknown user (authed)\n  type: http\n  seq: 6\n}\n\ndelete {\n  url: {{host}}/api/profiles/unknown-use"
  },
  {
    "path": "specs/api/bruno/favorites/01-setup-register.bru",
    "chars": 367,
    "preview": "meta {\n  name: Setup: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbo"
  },
  {
    "path": "specs/api/bruno/favorites/02-setup-create-article.bru",
    "chars": 432,
    "preview": "meta {\n  name: Setup: Create article\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: n"
  },
  {
    "path": "specs/api/bruno/favorites/03-favorite-article.bru",
    "chars": 913,
    "preview": "meta {\n  name: Favorite article\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/favorite\n  body: n"
  },
  {
    "path": "specs/api/bruno/favorites/04-verify-favorite-persists.bru",
    "chars": 911,
    "preview": "meta {\n  name: Verify favorite persists\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: non"
  },
  {
    "path": "specs/api/bruno/favorites/05-articles-filtered-by-favorited-username.bru",
    "chars": 1122,
    "preview": "meta {\n  name: Articles filtered by favorited username\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/articles?favor"
  },
  {
    "path": "specs/api/bruno/favorites/06-articles-filtered-by-favorited-username-with-auth.bru",
    "chars": 1178,
    "preview": "meta {\n  name: Articles filtered by favorited username with auth\n  type: http\n  seq: 6\n}\n\nget {\n  url: {{host}}/api/arti"
  },
  {
    "path": "specs/api/bruno/favorites/07-unfavorite-article.bru",
    "chars": 918,
    "preview": "meta {\n  name: Unfavorite article\n  type: http\n  seq: 7\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}/favorite\n  bod"
  },
  {
    "path": "specs/api/bruno/favorites/08-verify-unfavorite-persists.bru",
    "chars": 914,
    "preview": "meta {\n  name: Verify unfavorite persists\n  type: http\n  seq: 8\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: n"
  },
  {
    "path": "specs/api/bruno/favorites/09-cleanup.bru",
    "chars": 202,
    "preview": "meta {\n  name: Cleanup\n  type: http\n  seq: 9\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none"
  },
  {
    "path": "specs/api/bruno/feed/01-register-main-user.bru",
    "chars": 379,
    "preview": "meta {\n  name: Register main user\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/02-register-celeb-user.bru",
    "chars": 381,
    "preview": "meta {\n  name: Register celeb user\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}"
  },
  {
    "path": "specs/api/bruno/feed/03-feed-for-new-user-returns-empty.bru",
    "chars": 340,
    "preview": "meta {\n  name: Feed for new user returns empty\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{host}}/api/articles/feed\n  body: "
  },
  {
    "path": "specs/api/bruno/feed/04-main-follows-celeb.bru",
    "chars": 305,
    "preview": "meta {\n  name: Main follows celeb\n  type: http\n  seq: 4\n}\n\npost {\n  url: {{host}}/api/profiles/feedc_{{uid}}/follow\n  bo"
  },
  {
    "path": "specs/api/bruno/feed/05-celeb-creates-article-1.bru",
    "chars": 436,
    "preview": "meta {\n  name: Celeb creates article 1\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth:"
  },
  {
    "path": "specs/api/bruno/feed/06-celeb-creates-article-2.bru",
    "chars": 436,
    "preview": "meta {\n  name: Celeb creates article 2\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth:"
  },
  {
    "path": "specs/api/bruno/feed/07-main-checks-feed.bru",
    "chars": 1191,
    "preview": "meta {\n  name: Main checks feed\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/articles/feed\n  body: none\n  auth: no"
  },
  {
    "path": "specs/api/bruno/feed/08-feed-with-limit-1.bru",
    "chars": 1140,
    "preview": "meta {\n  name: Feed with limit=1\n  type: http\n  seq: 8\n}\n\nget {\n  url: {{host}}/api/articles/feed?limit=1\n  body: none\n "
  },
  {
    "path": "specs/api/bruno/feed/09-feed-with-limit-1-offset-1.bru",
    "chars": 352,
    "preview": "meta {\n  name: Feed with limit=1&offset=1\n  type: http\n  seq: 9\n}\n\nget {\n  url: {{host}}/api/articles/feed?limit=1&offse"
  },
  {
    "path": "specs/api/bruno/feed/10-cleanup-delete-articles.bru",
    "chars": 227,
    "preview": "meta {\n  name: Cleanup: delete articles\n  type: http\n  seq: 10\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug1}}\n  body"
  },
  {
    "path": "specs/api/bruno/feed/11-delete-slug2.bru",
    "chars": 219,
    "preview": "meta {\n  name: DELETE {{slug2}}\n  type: http\n  seq: 11\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug2}}\n  body: none\n "
  },
  {
    "path": "specs/api/bruno/feed/12-cleanup-unfollow.bru",
    "chars": 230,
    "preview": "meta {\n  name: Cleanup: unfollow\n  type: http\n  seq: 12\n}\n\ndelete {\n  url: {{host}}/api/profiles/feedc_{{uid}}/follow\n  "
  },
  {
    "path": "specs/api/bruno/pagination/01-setup-register.bru",
    "chars": 369,
    "preview": "meta {\n  name: Setup: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbo"
  },
  {
    "path": "specs/api/bruno/pagination/02-create-article-1.bru",
    "chars": 421,
    "preview": "meta {\n  name: Create article 1\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}"
  },
  {
    "path": "specs/api/bruno/pagination/03-create-article-2.bru",
    "chars": 556,
    "preview": "meta {\n  name: Create article 2\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}"
  },
  {
    "path": "specs/api/bruno/pagination/04-list-with-limit-1-most-recent-first-so-slug2.bru",
    "chars": 393,
    "preview": "meta {\n  name: List with limit=1 (most recent first, so slug2)\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/articl"
  },
  {
    "path": "specs/api/bruno/pagination/05-list-with-limit-1-offset-1-second-page-so-slug1.bru",
    "chars": 405,
    "preview": "meta {\n  name: List with limit=1&offset=1 (second page, so slug1)\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/art"
  },
  {
    "path": "specs/api/bruno/pagination/06-cleanup.bru",
    "chars": 203,
    "preview": "meta {\n  name: Cleanup\n  type: http\n  seq: 6\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug1}}\n  body: none\n  auth: non"
  },
  {
    "path": "specs/api/bruno/pagination/07-delete-slug2.bru",
    "chars": 212,
    "preview": "meta {\n  name: DELETE {{slug2}}\n  type: http\n  seq: 7\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug2}}\n  body: none\n  "
  },
  {
    "path": "specs/api/bruno/profiles/01-register-main-user.bru",
    "chars": 372,
    "preview": "meta {\n  name: Register main user\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n"
  },
  {
    "path": "specs/api/bruno/profiles/02-register-celeb-user.bru",
    "chars": 305,
    "preview": "meta {\n  name: Register celeb user\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}"
  },
  {
    "path": "specs/api/bruno/profiles/03-get-profile-without-auth.bru",
    "chars": 415,
    "preview": "meta {\n  name: Get profile without auth\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{host}}/api/profiles/celeb_{{uid}}\n  body"
  },
  {
    "path": "specs/api/bruno/profiles/04-get-profile-with-auth.bru",
    "chars": 458,
    "preview": "meta {\n  name: Get profile with auth\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/profiles/celeb_{{uid}}\n  body: n"
  },
  {
    "path": "specs/api/bruno/profiles/05-follow-profile.bru",
    "chars": 458,
    "preview": "meta {\n  name: Follow profile\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/profiles/celeb_{{uid}}/follow\n  body: "
  },
  {
    "path": "specs/api/bruno/profiles/06-unfollow-profile.bru",
    "chars": 463,
    "preview": "meta {\n  name: Unfollow profile\n  type: http\n  seq: 6\n}\n\ndelete {\n  url: {{host}}/api/profiles/celeb_{{uid}}/follow\n  bo"
  }
]

// ... and 45 more files (download for full content)

About this extraction

This page contains the full source code of the realworld-apps/realworld GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 245 files (417.4 KB), approximately 120.3k tokens, and a symbol index with 63 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!