[{"data":1,"prerenderedAt":126},["ShallowReactive",2],{"markdown-posts":3},[4,14,25,34,43,52,62,71,81,90,99,108,117],{"title":5,"title_slug":6,"tags":7,"meta_description":9,"content":10,"image":11,"_created":12,"_modified":13},"Automated Offsite Encrypted Backups with Borg Backup","automated-offsite-encrypted-backups-with-borg-backup",[8],"backups","In this post I'll be showing you how to set up Borg Backup to backup the important files on your VPS or local work station to a remote repository. For this tutorial I'm going to be using borgbase.com as the remote repository provider but there are other options you can choose.","[Borgbase.com](https:\u002F\u002Fborgbase.com) was created specifically to host Borg repositories, it has a nice free plan that gives you 5GB of storages and allows upto 2 repositories. If you have a small VPS then this might be enough for you.\n\nIf you don't want to use borgbase you could use [rysnc.net](https:\u002F\u002Fwww.rsync.net\u002Fproducts\u002Fattic.html) or even another VPS that you run.\n\nThis tutorial will be done on a fresh Digital Ocean droplet with Ubuntu 18.04. \n\nCommands will be run by a user named johndoe with sudo privileges.\n\n## Install Borg Backup\n\nFirst things first we need to install Borg, luckily we can find it in Ubuntu's software repositories.\n\n```bash\nsudo apt update\nsudo apt install borgbackup\n```\n\nYou can check everything worked correctly by running `borg --version`, you should see something like `borg 1.1.5`, which is the version at the time of writing this post.\n\n## Install Python 3 and PIP\n\nNow we need to install Python 3 along with PIP so that we can install Borgmatic.\n\nBorgmatic is a wrapper for Borg that allows us to manage backups with easy to use configuration files. It is not required to use Borg but I'm going to use it here to show you how it works.\n\nYou may already have Python 3 installed (I think 18.04 does by default). You can run the commands below to check.\n\n```bash\npython --version\npython3 --version\n```\n\nIf Python 3 is already installed check what version of Python PIP is currently using, it might not be installed at all.\n\n```bash\npip --version\npip3 --version\n```\n\nIf PIP returns (python 2.7) at the end or it is not installed at all then we need to install PIP for Python 3.\n\n```bash\nsudo apt install python3-pip python3-setuptools\n```\n\nMake sure everything was installed correctly by running `pip3 --version`.\n\nNext install the following package that is used by Borgmatic.\n\n```bash\npip3 install wheel\n```\n\n## Install Borgmatic\n\nUsing PIP install Borgmatic for your user (johndoe in my case).\n\n```bash\npip3 install --user --upgrade borgmatic\n```\n\nYou may need to edit your `~\u002F.bashrc` file to include these commands in your PATH by adding the following to the end of the file.\n\n```\nexport PATH=\"$HOME\u002F.local\u002Fbin:$PATH\"\n```\n\nThen running `source ~\u002F.bashrc` to update it for your current session.\n\nNext we can publish the default configuration file by running the following.\n\n```bash\nsudo env \"PATH=$PATH\" generate-borgmatic-config\n```\n\nThe reason we pass `env \"PATH=$PATH\"` is to make sure we still have the borgmatic commands in our PATH when running sudo.\n\nWe could edit the `secure_path` in \u002Fetc\u002Fsudoers to include \u002Fhome\u002Fjohndoe\u002F.local\u002Fbin but I'll leave it as it is for this tutorial.\n\nBefore we go and edit the config file we'll first generate a new key pair and create our remote repository in borgbase.\n\n## Generate New ssh Key Pair\n\nTo generate the key pair run the following command:\n\n```bash\nssh-keygen -t ed25519 -a 100\n```\n\nCall it `\u002Fhome\u002Fjohndoe\u002F.ssh\u002Fborg_id_ed25519` making sure to replace `johndoe` with the your username and leave the passphrase as empty.\n\nThis will generate an Ed25519 key, which is shorter and faster than a comparable RSA key.\n\n```bash\ncat ~\u002F.ssh\u002Fborg_id_ed25519.pub\n```\n\nCopy this **public** key and add it to your BorgBase account by clicking \"ACCOUNT\" and then \"ADD KEY\". \n\n## Create Repository in BorgBase\n\nGive it a name you'll recognise for your server and add the new key above to the Append-only access section.\n\n\u003Cdiv class=\"blog-image\">\n\n![BorgBase Repository](\u002Fimages\u002Fposts\u002F5d121b2f717a4borgbase-repo.png)\n\u003C\u002Fdiv>\n\nCopy the repo path as we'll be adding it to the config file next, it will be something like `c89dks9m@c89dks9m.repo.borgbase.com:repo`.\n\n## Editing the Config File\n\nOpen \u002Fetc\u002Fborgmatic\u002Fconfig.yaml by running `sudo nano \u002Fetc\u002Fborgmatic\u002Fconfig.yaml` and edit its contents to look something like this:\n\n```yaml\n# Where to look for files to backup, and where to store those backups. See\n# https:\u002F\u002Fborgbackup.readthedocs.io\u002Fen\u002Fstable\u002Fquickstart.html and\n# https:\u002F\u002Fborgbackup.readthedocs.io\u002Fen\u002Fstable\u002Fusage.html#borg-create for details.\nlocation:\n    # List of source directories to backup (required). Globs and tildes are expanded.\n    source_directories:\n        - \u002Froot\n        - \u002Fhome\n        - \u002Fetc\n        - \u002Fvar\u002Flog\u002Fsyslog*\n\n    # Paths to local or remote repositories (required). Tildes are expanded. Multiple\n    # repositories are backed up to in sequence. See ssh_command for SSH options like\n    # identity file or port.\n    repositories:\n        - YOUR-REPO-ID@YOUR-REPO-ID.repo.borgbase.com:repo\n\n    # Stay in same file system (do not cross mount points). Defaults to false.\n    #one_file_system: true\n\n    # Only store\u002Fextract numeric user and group identifiers. Defaults to false.\n    #numeric_owner: true\n\n    # Use Borg's --read-special flag to allow backup of block and other special\n    # devices. Use with caution, as it will lead to problems if used when\n    # backing up special devices such as \u002Fdev\u002Fzero. Defaults to false.\n    #read_special: false\n\n    # Record bsdflags (e.g. NODUMP, IMMUTABLE) in archive. Defaults to true.\n    #bsd_flags: true\n\n    # Mode in which to operate the files cache. See\n    # https:\u002F\u002Fborgbackup.readthedocs.io\u002Fen\u002Fstable\u002Fusage\u002Fcreate.html#description for\n    # details. Defaults to \"ctime,size,inode\".\n    #files_cache: ctime,size,inode\n\n    # Alternate Borg local executable. Defaults to \"borg\".\n    #local_path: borg1\n\n    # Alternate Borg remote executable. Defaults to \"borg\".\n    #remote_path: borg1\n\n    # Any paths matching these patterns are included\u002Fexcluded from backups. Globs are\n    # expanded. (Tildes are not.) Note that Borg considers this option experimental.\n    # See the output of \"borg help patterns\" for more details. Quote any value if it\n    # contains leading punctuation, so it parses correctly.\n    #patterns:\n    #    - R \u002F\n    #    - '- \u002Fhome\u002F*\u002F.cache'\n    #    - + \u002Fhome\u002Fsusan\n    #    - '- \u002Fhome\u002F*'\n\n    # Read include\u002Fexclude patterns from one or more separate named files, one pattern\n    # per line. Note that Borg considers this option experimental. See the output of\n    # \"borg help patterns\" for more details.\n    #patterns_from:\n    #    - \u002Fetc\u002Fborgmatic\u002Fpatterns\n\n    # Any paths matching these patterns are excluded from backups. Globs and tildes\n    # are expanded. See the output of \"borg help patterns\" for more details.\n    exclude_patterns:\n        - '*.pyc'\n        - ~\u002F*\u002F.cache\n    #    - \u002Fetc\u002Fssl\n\n    # Read exclude patterns from one or more separate named files, one pattern per\n    # line. See the output of \"borg help patterns\" for more details.\n    #exclude_from:\n    #    - \u002Fetc\u002Fborgmatic\u002Fexcludes\n\n    # Exclude directories that contain a CACHEDIR.TAG file. See\n    # http:\u002F\u002Fwww.brynosaurus.com\u002Fcachedir\u002Fspec.html for details. Defaults to false.\n    exclude_caches: true\n\n    # Exclude directories that contain a file with the given filename. Defaults to not\n    # set.\n    exclude_if_present: .nobackup\n\n# Repository storage options. See\n# https:\u002F\u002Fborgbackup.readthedocs.io\u002Fen\u002Fstable\u002Fusage.html#borg-create and\n# https:\u002F\u002Fborgbackup.readthedocs.io\u002Fen\u002Fstable\u002Fusage\u002Fgeneral.html#environment-variables for\n# details.\nstorage:\n    # The standard output of this command is used to unlock the encryption key. Only\n    # use on repositories that were initialized with passcommand\u002Frepokey encryption.\n    # Note that if both encryption_passcommand and encryption_passphrase are set,\n    # then encryption_passphrase takes precedence. Defaults to not set.\n    #encryption_passcommand: secret-tool lookup borg-repository repo-name\n\n    # Passphrase to unlock the encryption key with. Only use on repositories that were\n    # initialized with passphrase\u002Frepokey encryption. Quote the value if it contains\n    # punctuation, so it parses correctly. And backslash any quote or backslash\n    # literals as well. Defaults to not set.\n    encryption_passphrase: CHANGE-ME-TO-A-LONG-SECURE-PASSPHRASE\n\n    # Number of seconds between each checkpoint during a long-running backup. See\n    # https:\u002F\u002Fborgbackup.readthedocs.io\u002Fen\u002Fstable\u002Ffaq.html#if-a-backup-stops-mid-way-does-the-already-backed-up-data-stay-there\n    # for details. Defaults to checkpoints every 1800 seconds (30 minutes).\n    #checkpoint_interval: 1800\n\n    # Specify the parameters passed to then chunker (CHUNK_MIN_EXP, CHUNK_MAX_EXP,\n    # HASH_MASK_BITS, HASH_WINDOW_SIZE). See https:\u002F\u002Fborgbackup.readthedocs.io\u002Fen\u002Fstable\u002Finternals.html\n    # for details. Defaults to \"19,23,21,4095\".\n    #chunker_params: 19,23,21,4095\n\n    # Type of compression to use when creating archives. See\n    # https:\u002F\u002Fborgbackup.readthedocs.org\u002Fen\u002Fstable\u002Fusage.html#borg-create for details.\n    # Defaults to \"lz4\".\n    compression: auto,zstd\n\n    # Remote network upload rate limit in kiBytes\u002Fsecond. Defaults to unlimited.\n    #remote_rate_limit: 100\n\n    # Command to use instead of \"ssh\". This can be used to specify ssh options.\n    # Defaults to not set.\n    ssh_command: ssh -i \u002Fhome\u002Fjohndoe\u002F.ssh\u002Fborg_id_ed25519\n\n    # Base path used for various Borg directories. Defaults to $HOME, ~$USER, or ~.\n    # See https:\u002F\u002Fborgbackup.readthedocs.io\u002Fen\u002Fstable\u002Fusage\u002Fgeneral.html#environment-variables for details.\n    #borg_base_directory: \u002Fpath\u002Fto\u002Fbase\n\n    # Path for Borg configuration files. Defaults to $borg_base_directory\u002F.config\u002Fborg\n    #borg_config_directory: \u002Fpath\u002Fto\u002Fbase\u002Fconfig\n\n    # Path for Borg cache files. Defaults to $borg_base_directory\u002F.cache\u002Fborg\n    #borg_cache_directory: \u002Fpath\u002Fto\u002Fbase\u002Fcache\n\n    # Path for Borg security and encryption nonce files. Defaults to $borg_base_directory\u002F.config\u002Fborg\u002Fsecurity\n    #borg_security_directory: \u002Fpath\u002Fto\u002Fbase\u002Fconfig\u002Fsecurity\n\n    # Path for Borg encryption key files. Defaults to $borg_base_directory\u002F.config\u002Fborg\u002Fkeys\n    #borg_keys_directory: \u002Fpath\u002Fto\u002Fbase\u002Fconfig\u002Fkeys\n\n    # Umask to be used for borg create. Defaults to 0077.\n    #umask: 0077\n\n    # Maximum seconds to wait for acquiring a repository\u002Fcache lock. Defaults to 1.\n    #lock_wait: 5\n\n    # Name of the archive. Borg placeholders can be used. See the output of\n    # \"borg help placeholders\" for details. Defaults to\n    # \"{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}\". If you specify this option, you must\n    # also specify a prefix in the retention section to avoid accidental pruning of\n    # archives with a different archive name format. And you should also specify a\n    # prefix in the consistency section as well.\n    archive_name_format: '{hostname}-{now}'\n\n# Retention policy for how many backups to keep in each category. See\n# https:\u002F\u002Fborgbackup.readthedocs.org\u002Fen\u002Fstable\u002Fusage.html#borg-prune for details.\n# At least one of the \"keep\" options is required for pruning to work. See\n# https:\u002F\u002Ftorsion.org\u002Fborgmatic\u002Fdocs\u002Fhow-to\u002Fdeal-with-very-large-backups\u002F\n# if you'd like to skip pruning entirely.\nretention:\n    # Keep all archives within this time interval.\n    #keep_within: 3H\n\n    # Number of secondly archives to keep.\n    #keep_secondly: 60\n\n    # Number of minutely archives to keep.\n    #keep_minutely: 60\n\n    # Number of hourly archives to keep.\n    #keep_hourly: 24\n\n    # Number of daily archives to keep.\n    keep_daily: 7\n\n    # Number of weekly archives to keep.\n    keep_weekly: 4\n\n    # Number of monthly archives to keep.\n    keep_monthly: 6\n\n    # Number of yearly archives to keep.\n    keep_yearly: 1\n\n    # When pruning, only consider archive names starting with this prefix.\n    # Borg placeholders can be used. See the output of \"borg help placeholders\" for\n    # details. Defaults to \"{hostname}-\".\n    prefix: '{hostname}-'\n\n# Consistency checks to run after backups. See\n# https:\u002F\u002Fborgbackup.readthedocs.org\u002Fen\u002Fstable\u002Fusage.html#borg-check and\n# https:\u002F\u002Fborgbackup.readthedocs.org\u002Fen\u002Fstable\u002Fusage.html#borg-extract for details.\nconsistency:\n    # List of one or more consistency checks to run: \"repository\", \"archives\", and\u002For\n    # \"extract\". Defaults to \"repository\" and \"archives\". Set to \"disabled\" to disable\n    # all consistency checks. \"repository\" checks the consistency of the repository,\n    # \"archive\" checks all of the archives, and \"extract\" does an extraction dry-run\n    # of the most recent archive.\n    checks:\n        - repository\n        - archives\n\n    # Paths to a subset of the repositories in the location section on which to run\n    # consistency checks. Handy in case some of your repositories are very large, and\n    # so running consistency checks on them would take too long. Defaults to running\n    # consistency checks on all repositories configured in the location section.\n    #check_repositories:\n    #    - user@backupserver:sourcehostname.borg\n\n    # Restrict the number of checked archives to the last n. Applies only to the \"archives\" check. Defaults to checking all archives.\n    check_last: 3\n\n    # When performing the \"archives\" check, only consider archive names starting with\n    # this prefix. Borg placeholders can be used. See the output of\n    # \"borg help placeholders\" for details. Defaults to \"{hostname}-\".\n    prefix: '{hostname}-'\n\n# Options for customizing borgmatic's own output and logging.\n#output:\n    # Apply color to console output. Can be overridden with --no-color command-line\n    # flag. Defaults to true.\n    #color: false\n\n# Shell commands or scripts to execute before and after a backup or if an error has occurred.\n# IMPORTANT: All provided commands and scripts are executed with user permissions of borgmatic.\n# Do not forget to set secure permissions on this file as well as on any script listed (chmod 0700) to\n# prevent potential shell injection or privilege escalation.\nhooks:\n    # List of one or more shell commands or scripts to execute before creating a backup.\n    before_backup:\n        - echo \"`date` - Starting backup\"\n        - mysqldump --all-databases > \u002Fhome\u002Fjohndoe\u002Fdatabases.sql\n\n    # List of one or more shell commands or scripts to execute after creating a backup.\n    after_backup:\n        - echo \"`date` - Finished backup\"\n        - rm \u002Fhome\u002Fjohndoe\u002Fdatabases.sql\n\n    # List of one or more shell commands or scripts to execute in case an exception has occurred.\n    #on_error:\n    #    - echo \"Error while creating a backup.\"\n\n    # Umask used when executing hooks. Defaults to the umask that borgmatic is run with.\n    #umask: 0077\n```\n\nMake sure to change the encryption passphrase to a long secure secret and also update the repository addresss. Change the files to backup to suit your specific needs.\n\nIf you want to include your databases in the backup then you can use the before and after hooks (make sure again to change johndoe to the name of your user). If not then just comment out these lines. \n\nMake sure to backup your passphrase as you won't be able to decrypt your backups without it.\n\nRun the following command to check for any configuration errors.\n\n```bash\nsudo env \"PATH=$PATH\" validate-borgmatic-config\n```\n\nIf everything is okay you should see `All given configuration files are valid: \u002Fetc\u002Fborgmatic\u002Fconfig.yaml`.\n\n## Initialise the Backup Repository\n\n```bash\nsudo env \"PATH=$PATH\" borgmatic init --encryption repokey-blake2\n```\n\nYou'll be asked about the authenticity of the host when connecting for the first time. Check the ECDSA key fingerprint against the one shown in BorgBase by hovering over the fingerprint icon on the repository check the SHA256 to make sure it matches.\n\nThen enter yes to continue. You'll see a message saying Repository .... does not exist. This is simply becuase it it the first time you are running the command and it is currently being created.\n\n## Creating your First Backup\n\nTo create our first backup we can simply run the following:\n\n```bash\nsudo env \"PATH=$PATH\" borgmatic --verbosity 1\n```\n\nThe verbosity flag simply tells Borgmatic to print out all the files it is adding, quickly check through the list to make sure they all look correct as per your \u002Fetc\u002Fborgmatic\u002Fconfig.yaml file.\n\n## Automating Backups with a Cron Job\n\nSince we're using sudo to run borgmatic we need to edit our \u002Fetc\u002Fsudoers file to allow passwordless sudo for that particular command whilst running our cron job.\n\n```bash\nsudo visudo\n```\n\nAt the end of the file add the following:\n\n```\njohndoe ALL=(root) NOPASSWD: \u002Fhome\u002Fjohndoe\u002F.local\u002Fbin\u002Fborgmatic\n```\n\nThis will allow us to run our cron job with sudo and not be prompted for a sudo password.\n\nTo add a new cron job type `crontab -e` in the terminal.\n\nAdd the following line to the end of the file.\n\n```\n0 0 * * * sudo \u002Fhome\u002Fjohndoe\u002F.local\u002Fbin\u002Fborgmatic\n```\n\nThis will create a new backup everyday at midnight.\n\n## Multiple Repositories for Differents Apps\n\nIf you would like to separate your apps in different repositories or even to create a repository for backing up just your database you can create a new config file by running:\n\n```bash\nsudo env \"PATH=$PATH\" generate-borgmatic-config --destination \u002Fetc\u002Fborgmatic.d\u002Fapp1.yaml\n```\n\nYou can then go and update the new config file to your liking e.g. to make an hourly database backup.\n\nWhen setting up cron jobs for backups as above you can pass `--config \u002Fetc\u002Fborgmatic.d\u002Fapp1.yaml` to tell Borgmatic to only run the backup for that repository.\n\n```\n0 * * * * sudo \u002Fhome\u002Fjohndoe\u002F.local\u002Fbin\u002Fborgmatic --config \u002Fetc\u002Fborgmatic.d\u002Fapp1.yaml\n```\n\nThis will run our app1 config file every hour.\n\n## Checking Backups\n\nTo see all of your backup archives you can run:\n\n```bash\nsudo env \"PATH=$PATH\" borgmatic list\n```\n\nTo see details about usage and the size of archives you can run:\n\n```bash\nsudo env \"PATH=$PATH\" borgmatic info\n```\n\n## Restoring Backups\n\nTo restore a backup you need to first get the name of the archive using the above `borgmatic list` command.\n\nThe list command should display something like this:\n\n```\nhost-2019-01-01T04:05:06.070809      Tue, 2019-01-01 04:05:06 [...]\nhost-2019-01-02T04:06:07.080910      Wed, 2019-01-02 04:06:07 [...]\n```\n\nThen you can simply run:\n\n```bash\nsudo env \"PATH=$PATH\" borgmatic extract --archive host-2019-01-02T04:06:07.080910\n```\n\nYou can also extract specific files by running:\n\n```bash\nsudo env \"PATH=$PATH\" borgmatic extract --archive host-2019-01-02T04:06:07.080910 --restore-path \u002Fpath\u002F1 \u002Fpath\u002F2\n```\n\nMore information about extracting repositories and individual files can be found here - https:\u002F\u002Ftorsion.org\u002Fborgmatic\u002Fdocs\u002Fhow-to\u002Frestore-a-backup\u002F\n\nBorg has many more great features you can read about in the official docs here - https:\u002F\u002Fborgbackup.readthedocs.io\u002Fen\u002Fstable\u002F\n\nHopefully this has given you a quick overview regarding Borg's features and how simple it can be to set up.","\u002Fimages\u002Fposts\u002F5d121b2f717a4borgbase-repo.png",1561467779,1572426423,{"title":15,"title_slug":16,"tags":17,"meta_description":20,"content":21,"image":22,"_created":23,"_modified":24},"Building a Static Blog with Nuxt.js and Cockpit Headless CMS - Part 7: Post Comments","building-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-7-post-comments",[18,19],"nuxt","cockpit","In this post we'll be looking at adding  a comment system for our blog posts in Cockpit. The system will allow comments on our blog posts, nested replies, comment moderation, basic spam protection and even markdown support!","If you haven't read Parts 1, 2, 3, 4, 5 and 6 of this guide you can find them here:\n- [Part 1: Setup](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-1-setup\u002F)\n- [Part 2: Dynamic Routes](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-2-dynamic-routes)\n- [Part 3: Deployment](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-3-deployment\u002F)\n- [Part 4: Post Pagination](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-4-post-pagination)\n- [Part 5: Searching Posts](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-5-searching-posts\u002F)\n- [Part 6: Contact Forms](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-6-contact-forms\u002F)\n\n## Comment sections on static sites\n\nThere are a number of different ways you can go about adding comments to your static site. The most common option is usually by using a third party service and embedding the comments onto your page using an iframe. Some examples are:\n\n* [Disqus](https:\u002F\u002Fdisqus.com\u002F)\n* [Spot.IM](https:\u002F\u002Fwww.spot.im\u002F)\n* [Just Comments](https:\u002F\u002Fjust-comments.com)\n\nThere are also some pretty awesome self-hosted options like [Commento](https:\u002F\u002Fcommento.io\u002F) (the commenting platform I'm using for this site).\n\n## A Cockpit Comment System\n\nWe're going to take advantage of Cockpit forms and use that as a basis for setting up comments on our blog. \n\nHere's how it will work:\n\n1. Someone will fill out the comment form on our blog\n2. This comment will be saved as a form entry in a form called comments\n3. We will receive an email giving us the option to approve or view\u002Fdelete the comment\n4. If approved then our static site will be rebuilt to reflect the change and display the new comment\n\nThere's quite a lot more going on than that but it should give a basic overview.\n\n## Starting With Cockpit on the Backend\n\nTo get started we'll head over to Cockpit and create a new form called comments.\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Comments Form](\u002Fimages\u002Fposts\u002F5ca74eaf5db9dcockpit-comments-form.png)\n\u003C\u002Fdiv>\n\nMake sure you leave `save form data` as false, I'll explain why shortly.\n\nYou also need to set up SMTP mailer settings in your config if you have not done already. I explain how in my previous post on contact forms.\n\nYou should already have an API key you can use only for form submissions if you followed the last post in this series, if not create a new key and add this in the rules section `\u002Fapi\u002Fforms\u002Fsubmit\u002F*`.\n\nGive it a test by sending a post request with Insomnia or Postman to see if your token is working as expected.\n\nYou should receive a notification email but you won't see a new form entry saved as we set this to false above.\n\nThis new comments form is where new comments will be posted to and saved when they are awaiting approval.\n\nOnce approved we will be deleting the entry from here, but more on that later.\n\n## Creating a New Comments Collection\n\nWhen we approve a comment we are going to save it as a new collection entry and remove its entry from the comments form.\n\nThis will allow us to create a collectionLink (relationship) between our post and the comments for that post.\n\nThat way when we fetch our posts data from Cockpit we can also fetch the comments belonging to each post at the same time.\n\nSo head over to Collections in Cockpit and click `Add Collection`.\n\nOur new comments collection will have the following fields:\n\n* **name** (type text)\n* **email** (type text)\n* **body** (type markdown)\n* **notify_replies** (type boolean) (options `{\"default\": false}`)\n* **post** (type collectionlink) - (options `{\"link\": \"posts\", \"display\": \"title\", \"multiple\": false, \"limit\": false}`)\n* **parent_id** (type text)\n\nMake sure to include the options in the provided JSON options field when adding the notify_replies and post fields.\n\n\u003Cdiv class=\"blog-note\">\n    \u003Cb>Note:\u003C\u002Fb> You do not need to set the body as markdown if you don't want, you can simply choose textarea. We will need to santize the user markdown later on.\n\u003C\u002Fdiv>  \n\nWe set multiple to false in the post collectionLink, we've essentially created the inverse of a one to many relationship. (In Laravel this would be like `return $this->belongsTo('App\\Post');`)\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Comments Collection](\u002Fimages\u002Fposts\u002F5ca751fae052ccockpit-comments-collection.png)\n\u003C\u002Fdiv>\n\n## Updating Our Posts Collection\n\nIf you've been following along with this series you should already have a posts collection set up. If not I show you how in my first post of the series [here](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-1-setup\u002F).\n\nWe need to add a new collectionLink field called `comments` with the following options:\n\n```json\n{\n  \"link\": \"comments\",\n  \"display\": \"name\",\n  \"multiple\": true,\n  \"limit\": false\n}\n```\n\nNotice here that we've set multiple to true, this is essentially a one to many relationship. E.g. One post can have many comments. (In Laravel this would be like `return $this->hasMany('App\\Comment');`)\n\nYour posts collection should now look something like this:\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Posts Collection](\u002Fimages\u002Fposts\u002F5ca755c8d2e51cockpit-posts-collection.png)\n\u003C\u002Fdiv>\n\nOkay so we've created a new comments form, a comments collection and updated our posts collection. Next we need to look at how we go about approving new comments that arrive in our comments form.\n\n## Approving The New Comment With a Custom Endpoint\n\nWhen a new comment is made we need to be able to moderate it first before it is published to the site, that's why we keep all pending comments in our comments form first.\n\nWe're going to create a new custom endpoint in Cockpit that will allow us to simply click a link and approve a comment.\n\nWe first need to add a new custom API key that only has permission to approve comments. So head over to settings, API Access and add a new key with the following in the rules section `\u002Fapi\u002Fforms\u002Fapprove\u002Fcomments`. We don't want to share this key with anyone.\n\nTo add a new custom api endpoint create a new file at `config\u002Fapi\u002Fforms\u002Fapprove\u002F` called `comments.php` (you'll need to create the directories api, forms and approve) this will allow us to visit `https:\u002F\u002Fcms.yourdomain.com\u002Fapi\u002Fforms\u002Fapprove\u002Fcomments?id=xxxx&token=xxxx` to access it.\n\nWe'll be passing an `id` parameter of the comments form entry to the endpoint which is why I've included it in the url above.\n\nIn this file add the following:\n\n```php\n\u003C?php\n\u002F\u002F find the form entry using its id we included in the url\n$form_entry = cockpit('forms')->findOne('comments', ['_id' => $this->param('id', null)]);\n\nif (!$form_data = $form_entry['data']) {\n    return $this->stop('{\"error\": \"No form entry found\"}', 412);\n}\n\n\u002F\u002F find the post that this comment is for\n$post = cockpit('collections')->findOne('posts', ['_id' => $form_data['post_id']]);\n\nif (!$post) {\n    return $this->stop('{\"error\": \"No post found\"}', 412);\n}\n\n\u002F\u002F create a new comment in the comments collection with the form data and create collectionLink to the post\n$comment_data = [\n\t'parent_id' => $form_data['parent_id'],\n\t'name' => $form_data['name'],\n\t'email' => $form_data['email'],\n\t'body' => $form_data['comment'],\n\t'notify_replies' => $form_data['notify_replies'],\n\t'post' => [\n\t\t'_id' => $post['_id'],\n\t\t'link' => 'posts',\n\t\t'display' => $post['title']\n\t]\n];\n\n$comment = cockpit('collections')->save('comments', $comment_data);\n\n\u002F\u002F check if this is the first comment on the post, if so then $post['comments'] will be an empty string so we update it to an empty array to prevent the next line throwing an error\nif(!is_array($post['comments'])){\n\t$post['comments'] = [];\n}\n\n\u002F\u002F also add a collectionLink from the post to the new comment\n$post['comments'][] = [\n\t'_id' => $comment['_id'],\n\t'link' => 'comments',\n\t'display' => $comment['name']\n];\n\n$post = cockpit('collections')->save('posts', $post);\n\n\u002F\u002F delete the form entry from the comments form\ncockpit('forms')->remove('comments', ['_id' => $form_entry['_id']]);\n\n\u002F\u002F redirect to view the comments collection\n$this->reroute($this->baseUrl('\u002Fcollections\u002Fentries\u002Fcomments'));\n```\nI've tried to add comments to the above code to explain what's going but what we basically do is first find the entry in the comments form by its id (we included it in the url id=xxx).\n\nThen find the post that this pending comment belongs to. Then we save the new comment in the comments collection and create a collectionLink to the post.\n\nWe then update the post so that it also has a collectionLink to the new comment.\n\nFinally we remove the form entry and redirect to the comments collection page.\n\n## Saving The Form Entry\n\nWe're going to need to access our approve comment form API key in the next file we create. To avoid hard coding it and potentially accidently committing it to version control we will create a `.env` file in our Cockpit root directory. Inside this `.env` file enter:\n\n```\nAPPROVE_TOKEN=xxxxxx\nSITE_URL=https:\u002F\u002Fcms.yourdomain.com\n```\n\nMaking sure to replace xxxx with your actual \"approve comment\" api key from above and `SITE_URL` with the url of your Cockpit site (no trailing slash).\n\nNext create a new file in your Cockpit directory at config\u002Fbootstrap.php. Put the following inside:\n\n```php\n\u003C?php\n\u002F\u002F save the form entry and add its _id to data\n$app->on(\"forms.submit.before\", function($form, &$data, $frm, &$options) use ($app) {\n\n\tif($form === 'comments'){\n\n\t\t\u002F\u002F make sure the comment has a valid post_id that exists\n\t\tif(isset($data['post_id']) && $post = cockpit('collections')->findOne('posts', ['_id' => $data['post_id']])){\n\n\t\t\t$data['post_title'] = $post['title'];\n\n\t\t\t$entry = cockpit('forms')->save($form, ['data' => $data]);\n\n\t\t\t$data['id'] = $entry['_id'];\n\n\t\t} else {\n\t\t\t$app->stop('{\"error\": \"No post found\"}', 412);\n\t\t}\n\t}\n\n});\n```\n\nThis is an event that we hook into before the form is submitted. We first make sure it is the correct form (in our case called comments) then we make sure that the form data we receive has the `post_id` set and that an actual post with that ID exists in our database.\n\nIf it does then we add `post_title` to the data and then save the submission as a new entry. That is why when we created the comments form above we made sure to set `save form data` as false, otherwise it would save the entry twice.\n\nYou might be wondering why I'm saving the form entry now when it could have been saved anyway if we had just set `save form data` to true. The answer to that is because we need the `_id` of this entry so we can pass it through to our notification email and use it in our approve endpoint.\n\nSo after we save the entry we can retrieve its `_id` and add `$data['id']` to our data so we can use it in our email template along with our `SITE_URL` and `APPROVE_TOKEN` from our `.env` file.\n\n## Creating a Custom Email Notification Template\n\nIn Cockpit you can create custom email templates to override the default one. To do this you simply create a new file at `config\u002Fforms\u002Femails\u002F` with the same name as the form you wish to override.\n\nIn our case we need to create one called `comments.php`, once created add the following:\n\n```php\n@if( isset($data['post_title']) )\nA new comment is awaiting approval on \u003Cb>{{ $data['post_title'] }}\u003C\u002Fb>\n\u003Cbr>\u003Cbr>\n@endif\n\n@if( isset($data['name']) )\n\u003Cb>Name:\u003C\u002Fb>\n\u003Cbr>\n\u003Cbr>{{ htmlspecialchars($data['name'], ENT_QUOTES, 'UTF-8', true) }}\n\u003Cbr>\n@endif\n\n@if( isset($data['email']) )\n\u003Cbr>\u003Cb>Email:\u003C\u002Fb>\n\u003Cbr>\n\u003Cbr>{{ htmlspecialchars($data['email'], ENT_QUOTES, 'UTF-8', true) }}\n\u003Cbr>\n@endif\n\n@if( isset($data['comment']) )\n\u003Cbr>\u003Cb>Comment:\u003C\u002Fb>\n\u003Cbr>\n\u003Cbr>{{ htmlspecialchars($data['comment'], ENT_QUOTES, 'UTF-8', true) }}\n\u003Cbr>\n@endif\n\n@if( isset($data['id']) )\n\u003Cbr>\n\u003Ca href=\"{{ getenv('SITE_URL') }}\u002Fapi\u002Fforms\u002Fapprove\u002Fcomments?id={{ $data['id'] }}&token={{ getenv('APPROVE_TOKEN') }}\">Click here to approve this comment\u003C\u002Fa>\n\u003Cbr>\u003Cbr>\nor\n\u003Cbr>\n@endif\n\n\u003Cbr>\n\u003Ca href=\"{{ getenv('SITE_URL') }}\u002Fforms\u002Fentries\u002Fcomments\">View and delete it\u003C\u002Fa>\n```\n\nAll we are doing here is using the data to create an email that will tell us who made the comment, the comment itself and allow us to click a link to approve the comment.\n\nIt is **important** to make sure you include `htmlspecialchars($var, ENT_QUOTES, 'UTF-8', true)` to protect ourselves against a comment containing malicious scripts etc.\n\n## Testing The Template and Approval\n\nNow if you send a post request to submit the comments form with the correct data making sure you replace the `post_id` with the ID of one of your blog posts otherwise you won't be able to approve it. \n\n```json\n{\n\t\"form\": {\n\t\t\"post_id\": \"xxxxxxxx\",\n\t\t\"parent_id\": null,\n\t\t\"name\": \"John Doe\",\n\t\t\"email\": \"you@example.com\",\n\t\t\"comment\": \"This is my new comment.\",\n\t\t\"notify_replies\": true\n\t}\n}\n```\n\nAlso make sure you use one of your real email addresses for email with notify replies set as true as we will be replying to this comment later.\n\nYou can find the ID of one of your blog posts by making a GET request to `\u002Fapi\u002Fcollections\u002Fget\u002Fposts?token=xxxx` where xxxx is your posts collection API key. Choose a post and then copy the `_id` value.\n\nYou should receive an email notification that uses the custom template above and includes our approve url.\n\nYou can click on the approve url and if successful it should redirect you to the comments collection where you can see the newly created comment.\n\nYou'll notice that the comment has the name of the blog post in the `post` column. This is because we set the value display as `title` in the JSON options for the collectionLink.\n\nIf you view your posts collection entries you'll see that the comments column has a `1` in it.\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Post Comments](\u002Fimages\u002Fposts\u002F5ca8743f3096bcockpit-post-comments.png)\n\u003C\u002Fdiv>\n\nSo that means our post and comment are linked succesfully!\n\nTo see this in action you can make a POST request to `\u002Fapi\u002Fcollections\u002Fget\u002Fposts?token=xxx` with the body set as:\n\n```json\n{\"sort\":{\"_created\":-1},\"populate\":1}\n```\n\nThe `populate` option is important as it tells Cockpit to return and populate relationships 1 level deep. You should see in the response that each post has a `\"comments\": []` array. If you find the blog post you added the comment to you should see the comment there.\n\nIf you set populate to `-1` it will populate to infinite levels, however it can cause some issues and errors. \n\nTry setting populate to 0 or removing it and you'll notice that you won't get all fields returned for your comment.\n\n## Creating a New Custom Email Template For Comment Replies\n\nOkay so we can now add a comment using our form and then approve the comment but how about comment replies and notifying the parent comment?\n\nWell first off we need to create a new custom email template, so in `config\u002Fforms\u002Femails` create a new file called `notify_reply.php` and add the following inside:\n\n```php\n@if( isset($data['post_title']) )\nYour comment has a new reply on \u003Cb>{{ $data['post_title'] }}\u003C\u002Fb>\n\u003Cbr>\u003Cbr>\n@endif\n\n@if( isset($data['name']) )\n\u003Cb>Name:\u003C\u002Fb>\n\u003Cbr>\n\u003Cbr>{{ htmlspecialchars($data['name'], ENT_QUOTES, 'UTF-8', true) }}\n\u003Cbr>\n@endif\n\n@if( isset($data['comment']) )\n\u003Cbr>\u003Cb>Comment:\u003C\u002Fb>\n\u003Cbr>\n\u003Cbr>{{ htmlspecialchars($data['comment'], ENT_QUOTES, 'UTF-8', true) }}\n\u003Cbr>\n@endif\n\n@if( $data['post_url'] )\n\u003Cbr>\n\u003Ca href=\"{{ $data['post_url'] }}\">Click here to view the comment\u003C\u002Fa>\n@endif\n```\n\nAgain be sure to **include** `htmlspecialchars()` here! We'll pass all this data through to this template when we actually come to send the email.\n\n## Notifying The Parent Comment of New Replies\n\nNow let's add the actual code that will send an email to the parent comment when it receives a reply and that reply is approved.\n\nJust before we do open up your `.env` file and add the following:\n\n```\nFRONTEND_URL=https:\u002F\u002Fyourdomain.com\n```\n\nNote that there is no trailing slash. We'll be using this to create the url for the blog post with its `title_slug` e.g. `https:\u002F\u002Fyourdomain.com\u002Ffirst-blog-post`.\n\nOpen up `config\u002Fapi\u002Fforms\u002Fapprove\u002Fcomments.php` and update the following just after we remove the form entry:\n\n```php\n\u002F\u002F check if the comment has a valid parent comment and that it exists\nif(isset($comment['parent_id']) && $parent_comment = cockpit('collections')->findOne('comments', ['_id' => $comment['parent_id']])){\n\n\t\u002F\u002F check if the parent comment has notify_replies set to true\n\tif($parent_comment['notify_replies']){\n\t\t\n\t\t\u002F\u002F validate the email for the parent comment\n\t\tif($this->helper('utils')->isEmail($parent_comment['email'])){\n\n\t\t\t\u002F\u002F use our custom email template for a new reply notification\n            if ($template = $this->path(\"#config:forms\u002Femails\u002Fnotify_reply.php\")) {\n\n            \t$notify_data = [\n            \t\t'post_title' => $post['title'],\n            \t\t'name' => $comment['name'],\n            \t\t'comment' => $comment['body'],\n            \t\t'post_url' => getenv('FRONTEND_URL').'\u002F'.$post['title_slug']\n            \t];\n\n                $body = $this->renderer->file($template, ['data' => $notify_data], false);\n\n                \u002F\u002F send email to notify parent comment of a new reply\n            \ttry {\n                    $response = $this->mailer->mail($parent_comment['email'], \"New comment reply on: {$post['title']}\", $body);\n                } catch (\\Exception $e) {\n                    $response = $e->getMessage();\n                }\n            }\n\t\t}\n\t}\n}\n\n\u002F\u002F display error if present or redirect to view the comments collection\nreturn (isset($response) && $response !== true) ? ['error' => $response] : $this->reroute($this->baseUrl('\u002Fcollections\u002Fentries\u002Fcomments'));\n```\n\nSo what we're doing here is first checking to see if the comment has a `parent_id` value set, if it does and we find a comment with that ID in our database then check to see if the parent comment had `notify_replies` set to true.\n\nIf it does then we check if the parent comment's email is valid and if we have a custom template available called `notify_reply.php` (we do as we just created it).\n\nThen we pass the data through to the template and attempt to send the email using our mailer.\n\n## Testing The Comment Reply Notification\n\nIf you used one of your real email addresses and set `notify_replies` to true when testing the comment approval above then we can now try and reply to this comment.\n\nSo first we need to find out the ID for the comment we would like to reply to, to do this you can make a GET request like above to your posts collection endpoint and find the post with the comment, then copy the ID for the comment.\n\nNow we can make another form submission with the following data:\n\n```json\n{\n\t\"form\": {\n\t\t\"post_id\": \"xxxxxxxx\",\n\t\t\"parent_id\": \"xxxxxx\",\n\t\t\"name\": \"Jane Doe\",\n\t\t\"email\": \"you@example.com\",\n\t\t\"comment\": \"This is a reply to my first comment.\",\n\t\t\"notify_replies\": true\n\t}\n}\n```\n\nMaking sure to use the same `post_id` as before and the `parent_id` as the ID we just copied from the first comment.\n\nYou should receive the email notification to confirm or view\u002Fdelete the comment entry. Once you click approve you should then receive an email to the parent comment's email address letting you know your comment has a new reply.\n\n## Server Side Validation\n\nLet's finish up the backend by adding some server side validation for our comments form.\n\nIf you've read the previous post about contact forms you'll know how to do this. Create a new file at `config\u002Fforms\u002F` called `comments.php` (it must have the same name as the one we gave our form).\n\n```php\n\u003C?php\n\n\u002F\u002F honeypot field\nif (isset($data['website'])) {\n\n\t\u002F\u002F you can save the submission in case it is actually a genuine one like we did in the last blog post on contact forms, make sure you have a form set up called bots\n\tcockpit('forms')->save('bots', ['data' => $data]);\n\n\treturn false;\n}\n\nif (empty($data['post_id'])) {\n\treturn false;\n}\n\nif (empty($data['name'])) {\n\t$this->app->stop(['error' => 'The name field is required'], 200);\n}\n\nif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {\n\t$this->app->stop(['error' => 'A valid email is required'], 200);\n}\n\nif (empty($data['comment'])) {\n\t$this->app->stop(['error' => 'The comment field is required'], 200);\n}\n\nif (!is_bool($data['notify_replies'])) {\n\t$this->app->stop(['error' => 'Notify replies must be of type boolean'], 200);\n}\n\nreturn true;\n```\n\nSo we simply validate our comment form fields, we're going to use `website` as a honeypot field to catch bots like we did in the previous post. If the spam bot accidentally automatically fills in the website field we will return false.\n\nSo I think we've finished up setting the Cockpit side of the comment system up, let's now look at the Nuxt frontend. \n\n## Updating Our Nuxt .env and Config\n\nMoving on to Nuxt.js and our frontend let's first update our Nuxt .env file and add our `FORMS_TOKEN`. If you've followed the previous post you should already have this.\n\nNext open up nuxt.config.js and in the env property add:\n\n```javascript\nenv: {\n  commentUrl: `${process.env.BASE_URL}\u002Fapi\u002Fforms\u002Fsubmit\u002Fcomments?token=${process.env.FORMS_TOKEN}`\n},\n```\n\nThis is the endpoint we'll be posting our comments to. \n\n\u003Cdiv class=\"blog-note\">\n    \u003Cb>Note:\u003C\u002Fb> Anything we add to env in nuxt.config.js will be bundled up and public in our js files\n\u003C\u002Fdiv>  \n\nSo make sure not to include any sensitive API keys here. We obviously need the form endpoint and token to be public otherwise we won't be able to submit new comments from the frontend.\n\n## Updating The Blog Post Page\n\nOpen up your `_title_slug.vue` page (the individual blog page) and update it to resemble the following:\n\n```html\n\u003Ctemplate>\n  \u003Csection>\n    \u003Carticle class=\"my-8\">\n      \u003Cdiv class=\"text-gray-600 font-bold text-sm tracking-wide\">\n        {{ post._created | toDate }}\n        \u003Ca v-for=\"(tag, key) in post.tags\" :key=\"key\" :href=\"'\u002Fcategory\u002F'+tag\" class=\"ml-1\">{{ tag }}\u003C\u002Fa>\n      \u003C\u002Fdiv>\n      \u003Ch1 class=\"mt-2\">\n        {{ post.title }}\n      \u003C\u002Fh1>\n      \u003Cdiv class=\"mt-4 markdown\" v-html=\"$options.filters.parseMd(post.excerpt + '\\n' + post.content)\">\n      \u003C\u002Fdiv>\n\n      \u003Cdiv id=\"comments\" class=\"mt-8 mb-4 pt-3 border-t-2\">\n        \u003Ch2 class=\"mb-2\">\n          Comments\n        \u003C\u002Fh2>\n        \u003Ccomment-form class=\"border-b-2\" :post_id=\"post._id\"\u002F>\n      \u003C\u002Fdiv>\n\n      \u003Cul>\n        \u003Ccomment\n        v-for=\"comment in comments\"\n        :key=\"comment._id\"\n        :post_id=\"post._id\"\n        :all=\"post.comments\"\n        :comment=\"comment\"\n        \u002F>\n      \u003C\u002Ful>\n    \u003C\u002Farticle>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n\n```javascript\n\u003Cscript>\nimport CommentForm from '~\u002Fcomponents\u002FCommentForm.vue'\nimport Comment from '~\u002Fcomponents\u002FComment.vue'\n\nexport default {\n  async asyncData ({ app, params, error, payload }) {\n    if (payload) {\n      return { post: payload }\n    } else {\n      let { data } = await app.$axios.post(process.env.POSTS_URL,\n      JSON.stringify({\n          filter: { published: true, title_slug: params.title_slug },\n          sort: {_created:-1},\n          populate: 1\n        }),\n      {\n        headers: { 'Content-Type': 'application\u002Fjson' }\n      })\n\n      if (!data.entries) {\n        return error({ message: '404 Page not found', statusCode: 404 })\n      }\n\n      return { post: data.entries[0] }\n    }\n  },\n\n  components: {\n    CommentForm,\n    Comment\n  },\n\n  head () {\n    return {\n      title: this.post.title,\n      meta: [\n        { hid: 'description', name: 'description', content: this.post.excerpt },\n      ]\n    }\n  },\n\n  computed: {\n    comments: function () {\n      return this.post.comments ? this.post.comments.filter(comment => !comment.parent_id) : []\n    }\n  }\n}\n\u003C\u002Fscript>\n```\n\nThere are a few things to note here. We've got a `CommentForm` and a `Comment` component that we are yet to make. We pass the `comment-form` the current post ID as a prop. We loop over each comment and pass the `comment` component the post ID, all the comments for the post and the comment itself.\n\nIn the script section we register the Comment and CommentForm components.\n\nWe then have a computed property `comments` this simply returns all comments for our post that do not have a `parent_id` set e.g. they are top level comments.\n\nAt first I had comments set up with a collectionLink relationship to themselves so comments could have children and a parent. However I ran into issues whilst fetching the data relating to `populate` in the request and the depth it should be carried out to. For example if setting `populate: -1` in the request it would cause timeout errors for me.\n\nSo I decided instead to keep it simple and just add a parent_id to any child comment that references the ID of its parent.\n\nThat way I can organise the comments correctly in Nuxt by filtering only the parent comments and then recursively finding their children if they have any.\n\n## The Comment Form Component\n\nIn your components directory create a new file called `CommentForm.vue` and add the following inside:\n\n```html\n\u003Ctemplate>\n  \u003Cform @submit=\"checkForm\" method=\"post\" :id=\"parent_id ? `reply-${parent_id}` : ''\">\n    \u003Cdiv class=\"flex flex-col md:flex-row mb-4\">\n    \u003Cdiv class=\"w-full md:w-1\u002F2 md:mr-2\">\n      \u003Cinput v-model=\"name\" type=\"text\" name=\"name\" placeholder=\"Your Name\" class=\"block bg-gray-200 mt-2 rounded w-full py-2 px-3\">\n    \u003C\u002Fdiv>\n    \u003Cdiv class=\"w-full md:w-1\u002F2 md:ml-2\">\n      \u003Cinput v-model=\"email\" type=\"email\" name=\"email\" placeholder=\"Your Email\" class=\"block bg-gray-200 mt-2 rounded w-full py-2 px-3\">\n    \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n    \u003Cdiv class=\"mb-4\">\n      \u003Ctextarea v-model=\"comment\" name=\"comment\" rows=\"6\" :placeholder=\"parent_id ? `Reply to ${parent_name}...` : 'Add a comment'\" class=\"bg-gray-200 rounded resize-none w-full h-20 py-2 px-3\">\n      \u003C\u002Ftextarea>\n    \u003C\u002Fdiv>\n    \u003Cdiv class=\"mb-4\">\n      \u003Cinput v-model=\"notify_replies\" class=\"mr-2\" type=\"checkbox\">\n      \u003Cspan class=\"text-sm\">\n        Notify me when anyone replies\n      \u003C\u002Fspan>\n    \u003C\u002Fdiv>\n    \u003Cinput type=\"text\" name=\"website\" v-model=\"website\" class=\"hidden opacity-0 z-0\" tabindex=\"-1\" autocomplete=\"off\">\n    \u003Cdiv class=\"mb-4\">\n      \u003Cinput type=\"submit\" value=\"Add Comment\" :class=\"{ 'cursor-not-allowed opacity-50': loading }\" class=\"cursor-pointer bg-blue-500 hover:bg-blue-400 text-white font-bold py-2 px-4 border-b-4 border-blue-600 hover:border-blue-500 rounded\">\n    \u003C\u002Fdiv>\n    \u003Cdiv v-if=\"errors.length\" class=\"mb-4 text-red-500\">\n      \u003Cb>Please correct the following error(s):\u003C\u002Fb>\n      \u003Cul>\n        \u003Cli v-for=\"error in errors\" :key=\"error\">\n          {{ error }}\n        \u003C\u002Fli>\n      \u003C\u002Ful>\n    \u003C\u002Fdiv>\n    \u003Cdiv v-if=\"success\" class=\"text-green-500 mb-4\">\n      \u003Cb>Your comment is currently awaiting moderation\u003C\u002Fb>\n    \u003C\u002Fdiv>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\n```javascript\n\u003Cscript>\nimport axios from 'axios'\n\nexport default {\n  name: \"commentForm\",\n  props: {\n    post_id: String,\n    parent_id: String,\n    parent_name: String\n  },\n\n  data: function () {\n    return {\n      errors: [],\n      name: null,\n      email: null,\n      comment: null,\n      notify_replies: false,\n      website: null,\n      loading: false,\n      success: false\n    }\n  },\n\n  methods: {\n    checkForm: function (e) {\n      this.errors = []\n      this.success = false\n\n      if (!this.name) {\n        this.errors.push(\"Name required\")\n      }\n      if (!this.email) {\n        this.errors.push('Email required')\n      } else if (!this.validEmail(this.email)) {\n        this.errors.push('Valid email required')\n      }\n      if (!this.comment) {\n        this.errors.push(\"Comment required\")\n      }\n\n      if (!this.errors.length) {\n        this.submitForm()\n      }\n\n      e.preventDefault()\n    },\n\n    submitForm: function () {\n      this.loading = true\n\n      axios.post(process.env.commentUrl,\n      JSON.stringify({\n          form: {\n            post_id: this.post_id,\n            parent_id: this.parent_id,\n            name: this.name,\n            email: this.email,\n            comment: this.comment,\n            notify_replies: this.notify_replies,\n            website: this.website \u002F\u002Fhoneypot field\n          }\n        }),\n      {\n        headers: { 'Content-Type': 'application\u002Fjson' }\n      })\n      .then(({ data }) => {\n        this.loading = false\n\n        if(data.error){\n          this.errors.push(data.error)\n        } else if(data.name && data.email && data.comment) {\n          this.name = this.email = this.comment = null\n          this.success = true\n        }\n      }).catch(error => {\n        this.loading = false\n\n        this.errors.push('An error occured, please try again later')\n      })\n    },\n\n    validEmail: function (email) {\n      let re = \u002F^(([^\u003C>()\\[\\]\\\\.,;:\\s@\"]+(\\.[^\u003C>()\\[\\]\\\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$\u002F\n      return re.test(email)\n    }\n  }\n}\n\u003C\u002Fscript>\n```\n\nThe first thing to note is that this form is very similar to the contact form we did in the previous post.\n\nIf the component has a `parent_id` prop passed to it then we add an id to the form, you'll see why later. We also check for `parent_id` whilst setting the placeholder for the comment textarea, if there is a parent we reference the parent's name.\n\nWe need to import axios here as we're now calling it on the client side so can't use `app.$axios` as when in the `asyncData` function.\n\nThe form has some simple client side validation like our comment form and also the same  honeypot field called website.\n\nIf the form submission has any errors we display them and if it's successful we display a success message.\n\n## The Recursive Comment Component\n\nNow onto the Comment component, create a new file in the components directory called `Comment.vue` and add the following:\n\n```html\n\u003Ctemplate>\n  \u003Cli class=\"mb-4\" :class=\"!parent ? 'border-b-2' : ''\">\n    \u003Cdiv ref=\"parent\">\n      \u003Cdiv class=\"text-gray-600 text-sm mb-2\">\n        \u003Cspan class=\"text-gray-800 font-semibold\">\n          {{comment.name}}\n        \u003C\u002Fspan>\n        \u003Cspan class=\"mx-1 text-xs\">•\u003C\u002Fspan>\n        {{ comment._created | toDate }}\n        \u003Cspan v-if=\"parent\">\n          \u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" viewBox=\"-5 -5 24 24\" width=\"12\" height=\"12\" preserveAspectRatio=\"xMinYMin\" class=\"inline-block text-gray-600 fill-current\">\n            \u003Cpath d=\"M10.586 5.657l-3.95-3.95A1 1 0 0 1 8.05.293l5.657 5.657a.997.997 0 0 1 0 1.414L8.05 13.021a1 1 0 1 1-1.414-1.414l3.95-3.95H1a1 1 0 1 1 0-2h9.586z\">\u003C\u002Fpath>\n          \u003C\u002Fsvg>\n          {{ parent.name }}\n        \u003C\u002Fspan>\n      \u003C\u002Fdiv>\n\n      \u003Cdiv class=\"comment text-gray-800 text-base\" v-html=\"$options.filters.parseMd(comment.body)\">\u003C\u002Fdiv>\n\n      \u003Cdiv class=\"text-gray-600 text-sm mt-2 mb-4 cursor-pointer\" @click=\"toggleReply\">\n        \u003Cspan v-if=\"replyOpen\">Cancel\u003C\u002Fspan>\n        \u003Cspan v-else>Reply\u003C\u002Fspan>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cul class=\"ml-10 comment-list\" v-if=\"children(comment._id).length\">\n      \u003Ccomment\n      v-for=\"child in children(comment._id)\"\n      :key=\"child._id\"\n      :post_id=\"post_id\"\n      :all=\"all\"\n      :comment=\"child\"\n      :parent=\"comment\"\n      \u002F>\n    \u003C\u002Ful>\n  \u003C\u002Fli>\n\u003C\u002Ftemplate>\n```\n\n```javascript\n\u003Cscript>\nimport Vue from 'vue'\nimport CommentForm from '~\u002Fcomponents\u002FCommentForm.vue'\n\nexport default {\n  name: \"comment\",\n  props: {\n    post_id: String,\n    all: Array,\n    comment: Object,\n    parent: Object\n  },\n\n  data: function () {\n    return {\n      replyOpen: false\n    }\n  },\n\n  methods: {\n    children: function (parent_id) {\n      return this.all.filter(comment => comment.parent_id === parent_id)\n    },\n\n    toggleReply: function () {\n      if(!this.replyOpen){\n        let ComponentClass = Vue.extend(CommentForm)\n        let instance = new ComponentClass({\n            propsData: {\n              post_id: this.post_id,\n              parent_id: this.comment._id,\n              parent_name: this.comment.name\n            }\n        })\n        instance.$mount()\n        this.$refs.parent.appendChild(instance.$el)\n\n        this.replyOpen = true\n      } else {\n        \u002F\u002F remove the reply form from the DOM\n        let form = document.getElementById(`reply-${this.comment._id}`)\n\n        if(form){\n          this.$refs.parent.removeChild(form)\n\n          this.replyOpen = false\n        }\n      }\n    }\n  }\n}\n\u003C\u002Fscript>\n```\n\nThis component is a little more complex than the CommentForm one. At the top in the `li` tag we check if the comment has a parent. If it doesn't then we add a border to the bottom, just to add some separation between top level comments.\n\nWe then display the comment author's name, the date it was made (approved in our case) and the body of the comment. \n\nWe will be sanitizing the comment body shortly as it is not safe to use v-html on unsanitized user inputted data. A malicious actor could easily include javascript code on our site. \n\n\u003Cdiv class=\"blog-note\">\n    \u003Cb>Warning:\u003C\u002Fb> We are using v-html and parsing user inputted data here in the comment body. This is inherently unsafe, never do this without first sanitizing the data to prevent XSS attacks. We will sanitize in the next step.\n\u003C\u002Fdiv>\n\nIf earlier in this post when you set up the comments collection you chose not to support markdown and set the comment body field type as a textarea then you do not need to pass the `comment.body` through v-html or `$options.filters.parseMd()`.\n\nWe then have a div with `Reply` or `Cancel` depending on whether someone has clicked and opended a new comment form for that particular comment.\n\nFinally we have a section for any child comments, hence this being a recursive component. We include the component again inside itself if the current comment has any children.\n\nWe loop over the comment's children and pass through the necessary props, again passing down the `all` posts variable, the `post_id` and the `parent` comment.\n\nThe method we have called children simply filters the `all` comments prop and returns any comments that have the current comment's ID set as their `parent_id`.\n\nNow for the intersting part, handling comment replies. I needed a way to make sure the `parent_id` value was passed to the comment form if we were replying to a comment, that way we can identify which comment the reply belongs to.\n\nYou may have noticed that we imported `Vue` and `CommentForm`, this is so we can use them in the `toggleReply` method. In this method we first check to see if the `replyOpen` variable is set to false (e.g. the reply form is not active). \n\nWe then use [Vue.extend](https:\u002F\u002Fvuejs.org\u002Fv2\u002Fapi\u002F#Vue-extend) to create a \"subclass\" of the base Vue constructor, passing in our CommentForm component. Next we create a new instance of this class and pass it the relevant props, including the `parent_id` which is the ID of the current comment. Then we mount this without passing through any mount point.\n\nThe reason we do not pass any mount point is because we want to insert it into the DOM ourselves. The Vue docs state that:\n\n> If elementOrSelector argument is not provided, the template will be rendered as an off-document element, and you will have to use native DOM API to insert it into the document yourself.\n\nSo now we can insert this template by calling `this.$refs.parent.appendChild(instance.$el)` where `parent` is a reference we added to a div at the top of the comment component like so `ref=\"parent\"`.\n\nNow when we click on `Reply` the toggleReply function will be called and it will append a new instance of our CommentForm component to the end of this div.\n\nIf `replyOpen` is set to true then `Cancel` will be displayed instead of `Reply` and we will run the else portion of `toggleReply`. Here we simply find the comment form by id `reply-${this.comment._id}` and call again the parent reference using it to remove the comment form from the DOM and set replyOpen back to false.\n\n\u003Cdiv class=\"blog-image\">\n\n![Static Blog Comment Reply](\u002Fimages\u002Fposts\u002F5cac81a3e803dstatic-blog-comment-reply.png)\n\u003C\u002Fdiv>\n\n## Sanitizing The Markdown\n\nAs I mentioned above you cannot simply pass user inputted data through v-html as it will be rendered as actual html on the page. So if a user made a comment with this content:\n\n```html\n\u003Cscript>alert('Hello');\u003C\u002Fscript>\n```\n\nAnd we approved it, then whenever anybody visited the blog post with that comment on they would get an alert popup! You can read more about XSS attacks [here](https:\u002F\u002Fwww.owasp.org\u002Findex.php\u002FCross-site_Scripting_(XSS)).\n\nTo prevent against this we could either not run any user input through v-html (but then our markdown support wouldn't work) or first sanitize the data before displaying it on the page.\n\nI tried a few different html sanitzers and in the end settled on [Sanitize HTML](https:\u002F\u002Fgithub.com\u002Fpunkave\u002Fsanitize-html).\n\nOpen up the terminal in your Nuxt root and run:\n\n```bash\nnpm install sanitize-html --save-dev\n```\n\nNow that we've got it installed we need to use it, so open your filters.js file inside the plugins directory. Add the following to the top of the file:\n\n```javascript\nconst sanitizeHtml = require('sanitize-html')\n```\n\nand then update the `parseMd` filter:\n\n```javascript\nVue.filter('parseMd', function(content) {\n  let clean = sanitizeHtml(content)\n\n  return marked(clean)\n})\n```\n\nSo all we're doing here is first passing through the content to `sanitizeHtml` and then passing the cleaned content to `marked` to parse the markdown.\n\n\u003Cdiv class=\"blog-note\">\n    \u003Cb>Warning:\u003C\u002Fb> I have not tested this for all edge cases and cannot guarantee it is 100% safe in all occasions. Use in production at your own risk.\n\u003C\u002Fdiv>  \n\nIf you want to test your sanitization is working as it should be try posting a comment with the content from this [xss-payload-list](https:\u002F\u002Fgithub.com\u002Fismailtasdelen\u002Fxss-payload-list). \n\nSanitzeHtml seems to cope with this XSS payload well and mitigates all attempted attacks.\n\n## Adding a Little Styling\n\nLet's add a tiny bit of css for our comments, so update your main.css to the following:\n\n```css\n@tailwind base;\n\n@tailwind components;\n\na {\n  @apply text-blue-400;\n}\n\n.content {\n  width: 50rem;\n}\n\n.markdown p {\n  @apply mt-0 mb-6;\n}\n\n.markdown ul {\n  @apply mb-6;\n}\n\n.markdown pre {\n  @apply my-8;\n}\n\n.comment {\n  @apply whitespace-pre-wrap;\n}\n\n.comment p {\n  @apply mb-4 inline-block;\n}\n\n.comment p:last-of-type {\n  @apply mb-0;\n}\n\n.comment pre {\n  @apply my-4;\n}\n\n.comment pre:last-of-type {\n  @apply mb-0;\n}\n\n.comment p:last-child {\n  @apply mb-0;\n}\n\n\u002F* purgecss start ignore *\u002F\ntable {\n  @apply overflow-auto w-full;\n}\n\ntable tr {\n  @apply bg-white border-t border-gray-400;\n}\n\ntable th, table td {\n  @apply border border-gray-400 py-2 px-4;\n}\n\n.search-results em {\n  @apply not-italic bg-blue-200;\n}\n\u002F* purgecss end ignore *\u002F\n\n@tailwind utilities;\n```\n\nThe whitespace-pre-wrap will help make sure the comments display correctly on the page.\n\n\u003Cdiv class=\"blog-image\">\n\n![Static Blog Comments](\u002Fimages\u002Fposts\u002F5cac8cd2eaa9bstatic-blog-comments.png)\n\u003C\u002Fdiv>\n\n## Adding Hooks in Cockpit For When Deleting Comments or a Post\n\nWith our collectionLink between a post and its comments if you delete a comment we would like the deleted comment to be \"unlinked\" from the post.\n\nThis doesn't seem to happen by default so we need to add a `collections.remove.before.comments` hook to do it for us.\n\nSo in `config\u002Fbootstrap.php` add the following code:\n\n```php\n$app->on(\"collections.remove.before.comments\", function($name, &$criteria) use ($app) {\n\n\t\u002F\u002F find the comment using its id\n\t$comment = cockpit('collections')->findOne('comments', ['_id' => $criteria['_id']]);\n\n\tif(isset($comment['post']['_id'])){\n\n\t\t\u002F\u002F find the post it is currently linked to\n\t\t$post = cockpit('collections')->findOne('posts', ['_id' => $comment['post']['_id']]);\n\n\t\tif(isset($post['comments']) && is_array($post['comments'])){\n\n\t\t\t$comment_ids = array_column($post['comments'], '_id');\n\n\t\t\t$key = array_search($comment['_id'], $comment_ids);\n\n\t\t\tunset($post['comments'][$key]);\n\n\t\t\tcockpit('collections')->save('posts', $post);\n\t\t}\n\t}\n});\n```\n\nAll we are doing here is finding the comment we're about to delete, then finding the post it belongs to and removing the link by unsetting the corresponding array item in the `$post['comments']` array.\n\nNow we can also do the reverse, e.g. unlink all comments (or just delete them if we want) for a post when the post is deleted.\n\nSo add the following below the above:\n\n```php\n$app->on(\"collections.remove.before.posts\", function($name, &$criteria) use ($app) {\n\n\t\u002F\u002F find the post using its id\n\t$post = cockpit('collections')->findOne('posts', ['_id' => $criteria['_id']]);\n\n\tif(isset($post['comments']) && is_array($post['comments'])){\n\n\t\t\u002F\u002F loop over each linked comment\n\t\tforeach($post['comments'] as $item){\n\n\t\t\t$comment = cockpit('collections')->findOne('comments', ['_id' => $item['_id']]);\n\n\t\t\t\u002F\u002F set the post to an empty string\n\t\t\t$comment['post'] = \"\";\n\n\t\t\tcockpit('collections')->save('comments', $comment);\n\t\t}\n\t}\n});\n```\n\nNow this will simply unlink the comments but not delete them, if you'd like to just delete them update the loop to this:\n\n```php\n\u002F\u002F loop over each linked comment\nforeach($post['comments'] as $item){\n\n\t\u002F\u002F delete each linked comment\n\tcockpit('collections')->remove('comments', ['_id' => $item['_id']]);\n}\n```\n\n## Adding Comment Count to Posts\n\nTo add a little comment count to the top of each post you can edit a small part of the following files - `index.vue`, `_page.vue` and `_tag.vue` to the following:\n\n```html\n\u003Cdiv class=\"text-gray-600 font-bold text-sm tracking-wide\">\n  {{ post._created | toDate }}\n  \u003Cspan class=\"ml-1 text-xs\">•\u003C\u002Fspan>\n  \u003Ca v-for=\"tag in post.tags\" :key=\"tag\" :href=\"'\u002Fcategory\u002F'+tag\" class=\"ml-1\">#{{ tag }}\u003C\u002Fa>\n  \u003Cspan class=\"mx-1 text-xs\">•\u003C\u002Fspan>\n  \u003Cspan>\n    {{ post.comments ? post.comments.length : 0 }}\n    \u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" viewBox=\"-2 -2 24 24\" width=\"12\" height=\"12\" preserveAspectRatio=\"xMinYMin\" class=\"inline-block text-gray-600 fill-current\">\n      \u003Cpath d=\"M3 .565h14a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3h-6.958l-6.444 4.808A1 1 0 0 1 2 18.57v-4.006a2 2 0 0 1-2-2v-9a3 3 0 0 1 3-3z\">\u003C\u002Fpath>\n    \u003C\u002Fsvg>\n  \u003C\u002Fspan>\n\u003C\u002Fdiv>\n```\n\nand then in `_title_slug.vue` to this so we can click the comment count and be taken straight to the comment section:\n\n```html\n\u003Cdiv class=\"text-gray-600 font-bold text-sm tracking-wide\">\n  {{ post._created | toDate }}\n  \u003Cspan class=\"ml-1 text-xs\">•\u003C\u002Fspan>\n  \u003Ca v-for=\"tag in post.tags\" :key=\"tag\" :href=\"'\u002Fcategory\u002F'+tag\" class=\"ml-1\">#{{ tag }}\u003C\u002Fa>\n  \u003Cspan class=\"mx-1 text-xs\">•\u003C\u002Fspan>\n  \u003Ca href=\"#comments\" class=\"text-gray-600\">\n    {{ post.comments ? post.comments.length : 0 }}\n    \u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" viewBox=\"-2 -2 24 24\" width=\"12\" height=\"12\" preserveAspectRatio=\"xMinYMin\" class=\"inline-block text-gray-600 fill-current\">\n      \u003Cpath d=\"M3 .565h14a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3h-6.958l-6.444 4.808A1 1 0 0 1 2 18.57v-4.006a2 2 0 0 1-2-2v-9a3 3 0 0 1 3-3z\">\u003C\u002Fpath>\n    \u003C\u002Fsvg>\n  \u003C\u002Fa>\n\u003C\u002Fdiv>\n```\n\nIt should now look a little like this.\n\n\u003Cdiv class=\"blog-image\">\n\n![Static Blog Comment Count](\u002Fimages\u002Fposts\u002F5caccbcc5a5f2static-blog-comment-count.png)\n\u003C\u002Fdiv>\n\n## Closing Thoughts\n\nThis is only a basic example of a comment system and it could definitely be greatly improved but hopefully it gives you some ideas on what you can do with Cockpit.\n\nNow whenever a new comment is approved Cockpit will automatically fire our rebuild webhook from [part 3](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-3-deployment) of this series and run `npm run generate` again for our site!\n\nIf you notice any problems or can think of any improvements for this post feel free to add a comment or open an issue on Github.\n\nYou can check out the GitHub repo of the finished blog [here.](https:\u002F\u002Fgithub.com\u002Fwillbrowningme\u002Fnuxt-cockpit-static-blog)\n\nAlso I've just launched a live demo of this site on Netlify - [https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com](https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com\u002F)","\u002Fimages\u002Fposts\u002F5caccbcc5a5f2static-blog-comment-count.png",1553854880,1569938583,{"title":26,"title_slug":27,"tags":28,"meta_description":29,"content":30,"image":31,"_created":32,"_modified":33},"Building a Static Blog with Nuxt.js and Cockpit Headless CMS - Part 6: Contact Forms","building-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-6-contact-forms",[18,19],"In this post of our Nuxt Cockpit Static Blog series we'll be looking at how to add a basic contact form to our static site so we can receive enquiries from users. The form will have both client and server side validation. We'll also take a look at some basic spam protection measures we can add.","If you haven't read Parts 1, 2, 3, 4 and 5 of this guide you can find them here:\n- [Part 1: Setup](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-1-setup\u002F)\n- [Part 2: Dynamic Routes](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-2-dynamic-routes)\n- [Part 3: Deployment](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-3-deployment\u002F)\n- [Part 4: Post Pagination](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-4-post-pagination)\n- [Part 5: Searching Posts](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-5-searching-posts\u002F)\n\n## Handling Forms on Static Sites\n\nThere are a number of different ways you can go about handling forms on static sites, including:\n\n- Netlify\n- Google Forms\n- Formspree\n- 99 Inbound\n\nCockpit comes with its own solution that can help us add forms to our site using simple API POST requests to handle submissions.\n\nSubmissions made through the API can be viewed in the Cockpit dashboard and also notify you via email when a new submission is made.\n\nYou can read more about Cockpit forms in the documentation [here](https:\u002F\u002Fgetcockpit.com\u002Fdocumentation\u002Fmodules\u002Fforms).\n\n## Adding a New API Token\n\nFirst things first we need to generate a new API token in Cockpit and also make sure it only has permissions to hit the forms endpoint.\n\nSo head over to Cockpit and go to Settings then API Access. Click the little plus icon to add a new key and add the following to the rules field: `\u002Fapi\u002Fforms\u002Fsubmit\u002F*`.\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Forms API Key](\u002Fimages\u002Fposts\u002F5c812728321e5cockpit-forms-api-key.png)\n\u003C\u002Fdiv>\n\nNow this key will only be able to perform form submissions.\n\n## Creating a New Form in Cockpit\n\nIn order for Cockpit to handle submissions we first need to create a new form. So from the dashboard click on forms and then click 'Create one'.\n\nGive it a name like `contact` and a label of `Contact Form`. Add your email if you wish to be notified when new submissions are made. Turn on 'Save form data' if you would like to be able to view submission entries from Cockpit.\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Contact Form](\u002Fimages\u002Fposts\u002F5c81295621249cockpit-contact-form.png)\n\u003C\u002Fdiv>\n\nNow that we've created our contact form in Cockpit we can test it by sending a POST request to the right endpoint.\n\n## Updating Cockpit Mailer Config\n\nBefore we can test out our form we need to update our Mailer config and add some SMTP details. Go to settings and then click on settings and add SMTP details for the email address you'd like to use.\n\n> If you didn't enter an email when creating the form above you can skip adding SMTP details\n\nAdd the following inside config.yaml\n\n```yaml\n# use smtp to send emails\nmailer:\n    from      : you@example.com\n    transport : smtp\n    host      : smtp.myhost.tld\n    user      : you@example.com\n    password  : yourpassword\n    port      : 587\n    auth      : true\n    encryption: tls # '', 'ssl' or 'tls'\n```\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Mailer Config](\u002Fimages\u002Fposts\u002F5c812ddc7fdc3cockpit-mailer-config.png)\n\u003C\u002Fdiv>\n\n## Testing Our Contact Form\n\nIf you have [Postman](https:\u002F\u002Fwww.getpostman.com\u002F) or [Insomnia](https:\u002F\u002Finsomnia.rest\u002F) installed you can easily send a POST request to your Cockpit endpoint.\n\nThe endpoint we need to use is `cms.yourdomain.com\u002Fapi\u002Fforms\u002Fsubmit\u002Fcontact?token=xxx` where token is the API Key we created above to use for our form.\n\nMake sure to set a header with Content-Type as `application\u002Fjson` and then set the request body as the following JSON:\n\n```json \n{\n\t\"form\": {\n\t\t\"name\":\"John Doe\",\n\t\t\"email\":\"johndoe@example.com\",\n\t\t\"message\": \"This is the message body!\"\n\t}\n}\n```\n\nThe response returned if all was successful should just be the new form entry:\n\n```json\n{\n  \"name\": \"John Doe\",\n  \"email\": \"johndoe@example.com\",\n  \"message\": \"This is the message body!\"\n}\n```\n\nIf an error occurred (e.g. forgetting to update config.yaml) then the response will look something like this:\n\n```json\n{\n  \"error\": \"Invalid address:  (From): root@localhost\",\n  \"data\": {\n    \"name\": \"John Doe\",\n    \"email\": \"johndoe@example.com\",\n    \"message\": \"This is the message body!\"\n  }\n}\n```\n\nIf you head to `cms.yourdomain.com\u002Fforms\u002Fentries\u002Fcontact` you should now see the entry we just submitted via the API. You should also have received an email with the form submission details.\n\n## Updating Our Blogs Environment Variables\n\nOpen up your .env file for Nuxt and add a new variable called FORMS_TOKEN.\n\n```\nFORMS_TOKEN=xxxxxxxxxxxxxx\n```\n\nNow we also need to update the env property in our nuxt.config.js. Add the following anywhere inside module.exports = { ... }\n\n```javascript\nenv: {\n  contactUrl: `${process.env.BASE_URL}\u002Fapi\u002Fforms\u002Fsubmit\u002Fcontact?token=${process.env.FORMS_TOKEN}`\n},\n```\n\nAs mentioned in the previous post the reason we need to do this is because we will be making requests to contactUrl on the client side which means we need to have this variable bundled up in our js files.\n\n\u003Cdiv class=\"blog-note\">\n    Warning: Do not add any secret or sensitive details\u002Fkeys to the env property in nuxt.config.js as they will be publicly exposed in our js files\n\u003C\u002Fdiv>  \n\nMake sure you also update your create-env.js if deploying to Netlify. Also update your environment variables in Netlify.\n\n```javascript\nconst fs = require('fs')\nfs.writeFileSync('.\u002F.env', `\nBASE_URL=${process.env.BASE_URL}\\n\nPOSTS_URL=${process.env.POSTS_URL}\\n\nURL=${process.env.URL}\\n\nPER_PAGE=${process.env.PER_PAGE}\\n\nSEARCH_URL=${process.env.SEARCH_URL}\\n\nFORMS_TOKEN=${process.env.FORMS_TOKEN}\n`)\n```\n\n## Adding the Contact Page\n\nNow that we know our contact form is working as expected we can go and set it up in our blog.\n\nFirst we'll just update our PageNav.vue component to add a link to the new page:\n\n```html\n\u003Ctemplate>\n  \u003Cnav class=\"text-center my-4\">\n    \u003Ca href=\"\u002F\" class=\"p-2 text-sm sm:text-lg inline-block text-gray-800 hover:underline\">Blog\u003C\u002Fa>\n    \u003Ca href=\"\u002Fabout\" class=\"p-2 text-sm sm:text-lg p-2 inline-block text-gray-800 hover:underline\">About\u003C\u002Fa>\n    \u003Ca href=\"\u002Fsearch\" class=\"p-2 text-sm sm:text-lg p-2 inline-block text-gray-800 hover:underline\">Search\u003C\u002Fa>\n    \u003Ca href=\"\u002Fcontact\" class=\"p-2 text-sm sm:text-lg p-2 inline-block text-gray-800 hover:underline\">Contact\u003C\u002Fa>\n  \u003C\u002Fnav>\n\u003C\u002Ftemplate>\n```\n\nThen create a new file in the pages directory called `contact.vue` and put the following inside.\n\n```html\n\u003Ctemplate>\n  \u003Csection class=\"my-8\">\n    \u003Cdiv class=\"text-center\">\n      \u003Ch1 class=\"mb-6\">Contact Page\u003C\u002Fh1>\n      \u003Cp class=\"mb-8\">\n        This is a basic contact form working with Cockpit CMS!\n      \u003C\u002Fp>\n    \u003C\u002Fdiv>\n\n    \u003Cform @submit=\"checkForm\" method=\"post\">\n      \u003Cdiv class=\"mb-4\">\n        \u003Clabel for=\"name\">Name:\u003C\u002Flabel>\n        \u003Cinput v-model=\"name\" type=\"text\" name=\"name\" placeholder=\"Your Name\" class=\"block mt-2 bg-gray-200 rounded w-full py-2 px-3\">\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"mb-4\">\n        \u003Clabel for=\"mail\">Email:\u003C\u002Flabel>\n        \u003Cinput v-model=\"email\" type=\"email\" name=\"email\" placeholder=\"Your Email\" class=\"block mt-2 bg-gray-200 rounded w-full py-2 px-3\">\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"mb-4\">\n        \u003Clabel for=\"msg\">Message:\u003C\u002Flabel>\n        \u003Ctextarea v-model=\"message\" name=\"message\" placeholder=\"Your Message\" class=\"block mt-2 bg-gray-200 rounded w-full py-2 px-3\">\u003C\u002Ftextarea>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"mb-4\">\n        \u003Cinput type=\"submit\" value=\"Send message\" :class=\"{ 'cursor-not-allowed opacity-50': loading }\" class=\"cursor-pointer bg-blue-500 hover:bg-blue-400 text-white font-bold py-2 px-4 border-b-4 border-blue-600 hover:border-blue-500 rounded\">\n      \u003C\u002Fdiv>\n      \u003Cdiv v-if=\"errors.length\" class=\"mb-4 text-red-500\">\n        \u003Cb>Please correct the following error(s):\u003C\u002Fb>\n        \u003Cul>\n          \u003Cli v-for=\"error in errors\" :key=\"error\">\n            {{ error }}\n          \u003C\u002Fli>\n        \u003C\u002Ful>\n      \u003C\u002Fdiv>\n      \u003Cdiv v-if=\"success\" class=\"text-green-500\">\n        \u003Cb>Your message has been sent succesfully\u003C\u002Fb>\n      \u003C\u002Fdiv>\n    \u003C\u002Fform>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n\n```javascript\n\u003Cscript>\nimport axios from 'axios'\n\nexport default {\n  head () {\n    return {\n      title: 'Contact',\n      meta: [\n        { hid: 'description', name: 'description', content: 'This is the contact page!' }\n      ]\n    }\n  },\n\n  data: function () {\n    return {\n      errors: [],\n      name: null,\n      email: null,\n      message: null,\n      loading: false,\n      success: false\n    }\n  },\n\n  methods: {\n    checkForm: function (e) {\n      this.errors = []\n      this.success = false\n\n      if (!this.name) {\n        this.errors.push(\"Name required\")\n      }\n      if (!this.email) {\n        this.errors.push('Email required')\n      } else if (!this.validEmail(this.email)) {\n        this.errors.push('Valid email required')\n      }\n      if (!this.message) {\n        this.errors.push(\"Message required\")\n      }\n\n      if (!this.errors.length) {\n        this.submitForm()\n      }\n\n      e.preventDefault()\n    },\n\n    submitForm: function () {\n      this.loading = true\n\n      axios.post(process.env.contactUrl,\n      JSON.stringify({\n          form: {\n            name: this.name,\n            email: this.email,\n            message: this.message\n          }\n        }),\n      {\n        headers: { 'Content-Type': 'application\u002Fjson' }\n      })\n      .then(({ data }) => {\n        this.loading = false\n\n        if(data.error){\n          this.errors.push(data.error)\n        } else if(data.name && data.email && data.message) {\n          this.name = this.email = this.message = null\n          this.success = true\n        }\n      }).catch(error => {\n        this.loading = false\n\n        this.errors.push('An error occured, please try again later')\n      })\n    },\n\n    validEmail: function (email) {\n      let re = \u002F^(([^\u003C>()\\[\\]\\\\.,;:\\s@\"]+(\\.[^\u003C>()\\[\\]\\\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$\u002F\n      return re.test(email)\n    }\n  }\n}\n\u003C\u002Fscript>\n```\n\nAs you can see we have some basic fields for our form and on submitting the form we perform some client side validation following the example in the Vue documentation [here](https:\u002F\u002Fvuejs.org\u002Fv2\u002Fcookbook\u002Fform-validation.html). \n\nIf there are no client side validation errors then we call the submitForm method and use axios to make a POST request to our `contactUrl` endpoint. Then we simply display some text with a success message or an error if there is one present.\n\nIf no error is present we check if an entry has been returned with name, email and message details (this is what happens when a form is succesfully submitted).\n\nYou can fire up your local site using `npm run dev` and test this contact form out. You should receive an email notification and be able to see the entry in Cockpit.\n\n## Adding Server Side Validation\n\nAt the moment we only have validation for our form fields on the client side which can be circumvented, we need to also add validation for our fields in Cockpit.\n\nWe can add custom validation for our form fields in Cockpit by creating a new file with the same name as our form (in our case `contact`) in the config\u002Fforms directory. You will need to create the forms directory first.\n\nThen make a new file called `contact.php` and put the following inside:\n\n```php\n\u003C?php\n\nif (empty($data['name'])) {\n\t$this->app->stop(['error' => 'The name field is required'], 200);\n}\n\nif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {\n\t$this->app->stop(['error' => 'A valid email is required'], 200);\n}\n\nif (empty($data['message'])) {\n\t$this->app->stop(['error' => 'The message field is required'], 200);\n}\n\nreturn true;\n```\n\nThe form data is available in the $data variable. This is only really simple validation for an example.\n\nI initially had `return false;` inside each of the above valiation checks however it didn't give any information to the client about why the validation had failed. Instead we're stopping Cockpit and returning an error message with more details. You can return a 412 status code or something else if you like and handle these responses in axios `catch()` if you'd prefer.\n\nTo test out if this validation is working on the server we need to send a POST request using Postman\u002FInsomnia with `name` set to null.\n\nIf you don't have Postman or Insomnia just comment out the following in contact.vue to temporarily disable to client side validation then submit the form on the front end without setting a value for the name field:\n\n```javascript\ncheckForm: function (e) {\n  this.errors = []\n  this.success = false\n\n  \u002F* if (!this.name) {\n    this.errors.push(\"Name required\")\n  }\n  if (!this.email) {\n    this.errors.push('Email required')\n  } else if (!this.validEmail(this.email)) {\n    this.errors.push('Valid email required')\n  }\n  if (!this.message) {\n    this.errors.push(\"Message required\")\n  } *\u002F\n\n  if (!this.errors.length) {\n    this.submitForm()\n  }\n\n  e.preventDefault()\n},\n```\n\nNow if you've added the contact.php file correctly you should notice that the response is returned with an error message if validation fails on the server. You shouldn't receive a notification email and there should not be a new entry visible in Cockpit.\n\n## Spam Prevention\n\nIf you have any kind of contact form on your site it is very likely you will have received spam from automated bots.\n\nTo help prevent this you can add a Google reCAPTCHA to your site\u002Fform.\n\nIf you'd rather not use reCAPTCHA another simple method is available known as a Honeypot trap.\n\nThe idea is that you add a hidden text field or checkbox to your form that the user cannot see. A bot that is filling out the form will also accidently fill out this hidden field, in our server side validation we can check if this hidden field has been filled our (or checkbox ticked) and if it has we simply return false from our `contact.php` script.\n\nLet's add a really simple honeypot field to our form. Above the input button add this new field:\n\n```html\n\u003Cinput type=\"text\" name=\"website\" v-model=\"website\" class=\"hidden opacity-0 z-0\" tabindex=\"-1\" autocomplete=\"off\">\n```\n\nWe've given it a real looking name and set it to display: none, with 0 opacity and a z-index of 0. We've also set tabindex as -1 to prevent the user selecting the field by clicking tab and set autocomplete as off to prevent a user's browser accidently autocompleting and filling in the field.\n\nMake sure to add website to the page's data:\n\n```javascript\ndata: function () {\n  return {\n    errors: [],\n    name: null,\n    email: null,\n    message: null,\n    website: null,\n    loading: false,\n    success: null\n  }\n},\n```\n\nAlso add it when posting the request to Cockpit:\n\n```javascript\nsubmitForm: function () {\n  this.loading = true\n\n  axios.post(process.env.contactUrl,\n  JSON.stringify({\n      form: {\n        name: this.name,\n        email: this.email,\n        message: this.message,\n        website: this.website\n      }\n    }),\n  {\n    headers: { 'Content-Type': 'application\u002Fjson' }\n  })\n  .then(({ data }) => {\n    this.loading = false\n\n    if(data.error){\n      this.errors.push(data.error)\n    } else if(data.name && data.email && data.message) {\n      this.name = this.email = this.message = null\n      this.success = true\n    }\n  }).catch(error => {\n    this.loading = false\n\n    this.errors.push('An error occured, please try again later')\n  })\n},\n```\n\nNow all that's left to do is to update `contact.php` in the config\u002Fforms directory.\n\n```php\n\u003C?php\n\nif (isset($data['website'])) {\n\treturn false;\n}\n\nif (empty($data['name'])) {\n    $this->app->stop(['error' => 'The name field is required'], 200);\n}\n\nif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {\n    $this->app->stop(['error' => 'A valid email is required'], 200);\n}\n\nif (empty($data['message'])) {\n    $this->app->stop(['error' => 'The message field is required'], 200);\n}\n\nreturn true;\n```\n\nWe just add a check for the new website honeypot field, if it is not set to null then the submission will fail vailidation and be rejected. We're just returning false here instead of a validation error message but you can add one if you like.\n\nThe only potential downside to this method of spam prevention is if a real user someone manages to accidently fill in the website field and their legitimate submission is rejected.\n\nTo make sure we don't lose a genuine submission we should add logging or save all entries that fail the honeypot field test. That way we can check every so often which submissions have been rejected and see if any are authentic.\n\nOne way we could do this is by creating a new form called `bots` without setting an email and without setting save form data as true.\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Bots Form](\u002Fimages\u002Fposts\u002F5ca72b7ee69b3cockpit-bots-form.png)\n\u003C\u002Fdiv>\n\nThen we can just update our custom validation for contact at `config\u002Fforms\u002Fcontact.php` and add the following:\n\n```php\n\u003C?php\n\nif (isset($data['website'])) {\n\n\t\u002F\u002F save the submission in case it is actually a genuine one\n\tcockpit('forms')->save('bots', ['data' => $data]);\n\n\treturn false;\n}\n\nif (empty($data['name'])) {\n    $this->app->stop(['error' => 'The name field is required'], 200);\n}\n\nif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {\n    $this->app->stop(['error' => 'A valid email is required'], 200);\n}\n\nif (empty($data['message'])) {\n    $this->app->stop(['error' => 'The message field is required'], 200);\n}\n\nreturn true;\n```\n\nNow if you send a POST request to your contact form and make sure to set website as some value then you should see the submission is saved in your bots form entries at `cms.yourdomain.com\u002Fforms\u002Fentries\u002Fbots`.\n\nThis obviously doesn't prevent against a bot sending direct POST requests to our form's endpoint and omitting the website field but it should be fine for most situations.\n\nIf you want to make sure that ONLY the fields you want can be posted to your form then you can add something like this to your validation:\n\n```php\nforeach($data as $field => $value){\n\tif(!in_array($field, ['website', 'name', 'email', 'message'])){\n\t\treturn false;\n\t}\n}\n```\n\nNow if any additional field is added or sent the validation will fail.\n\nYou can always change the name of the honeypot field or update it to a checkbox if you notice spam coming through.\n\nYou should now have a contact form with client + server side validation and basic spam bot protection that looks like this:\n\n\u003Cdiv class=\"blog-image\">\n\n![Contact Form](\u002Fimages\u002Fposts\u002F5ca72f15d8137contact-form.png)\n\u003C\u002Fdiv>\n\nYou can check out the GitHub repo of the finished blog [here](https:\u002F\u002Fgithub.com\u002Fwillbrowningme\u002Fnuxt-cockpit-static-blog) and see a live demo of the site on Netlify here - [https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com](https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com\u002F)","\u002Fimages\u002Fposts\u002F5ca72f15d8137contact-form.png",1551964288,1569937523,{"title":35,"title_slug":36,"tags":37,"meta_description":38,"content":39,"image":40,"_created":41,"_modified":42},"Building a Static Blog with Nuxt.js and Cockpit Headless CMS - Part 5: Searching Posts","building-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-5-searching-posts",[18,19],"In this post of our Nuxt Cockpit series we'll be looking at how to add basic live search functionality for our blog posts so that readers can quickly find what they're looking for. To achieve this we'll be using an awesome service called Algolia.","If you haven't read Parts 1, 2, 3 and 4 of this guide you can find them here:\n- [Part 1: Setup](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-1-setup\u002F)\n- [Part 2: Dynamic Routes](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-2-dynamic-routes)\n- [Part 3: Deployment](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-3-deployment\u002F)\n- [Part 4: Post Pagination](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-4-post-pagination)\n\n## Adding Live Search on a Static Blog\n\nTypically when searching for something you would submit a form that would then be sent to a backend which would then query a database and return the results.\n\nIf you want to implement live searching you need to send requests in real time as the user is typing so results can be displayed almost immediately.\n\nSince our site is just a static blog made up of plain old HTML, CSS and Javascript we'll need to send requests elsewhere to get our search results.\n\nLuckily for us Cockpit has a full-text search addon called [Detektivo](https:\u002F\u002Fgithub.com\u002Fagentejo\u002FDetektivo). To install this addon you simply need to add the files to your Cockpit CMS directory under addons\u002FDetektivo.\n\nYou can do this by running the following commands from the command line.\n\n```bash\ncd \u002Fpath\u002Fto\u002Fyour\u002Fcms-yourblog\u002F\ncd addons\ngit clone https:\u002F\u002Fgithub.com\u002Fagentejo\u002FDetektivo.git\n```\n\nDetektivo supports a few different engines; Algolia, ElasticSearch and TNTSearch. We will be using [Algolia](https:\u002F\u002Fwww.algolia.com\u002F) here so visit the website and create an account (It has a great free tier).\n\nOnce you've created your Algolia account you can get your Application ID and Admin API Key. We need the Admin Key and not the Search-Only Key as we will be using it to add\u002Fupdate index records to be searched. \n\nNow we need to update our config.yaml file in Cockpit. So go to settings and then click settings again and you should see a text editor.\n\nAdd the following inside:\n\n```yaml\n# Search settings\ndetektivo:\n    engine: algolia\n    app_id: \u003CYOUR-APP-ID>\n    api_key: \u003CYOUR-API-KEY>\n    collections:\n        posts: [title, title_slug, excerpt]\n```\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Search Config](\u002Fimages\u002Fposts\u002F5c77fc2393441cockpit-search-config.png)\n\u003C\u002Fdiv>\n\nUnder collections you can see 'posts', this is in reference to our posts collection. The array  containing title, title_slug and excerpt are the fields in this collection that we wish to be included in our index in Algolia.\n\n\u003Cdiv class=\"blog-note\">\n    Note: You can add the post content field here as well as the excerpt however be aware that this will potentially make the records too big and you may get errors from Algolia when using the API\n\u003C\u002Fdiv>\n\nRead more about record size limits here - https:\u002F\u002Fwww.algolia.com\u002Fdoc\u002Ffaq\u002Fbasics\u002Fis-there-a-size-limit-for-my-index-records\u002F\n\nIt is probably best to do just the title, title_slug and excerpt fields to be on the safe side.\n\n## Adding Our Posts Index to Algolia\n\nWhilst logged in to Algolia create a new index and call it `posts`. There won't be any records here yet as we have not added them.\n\nIn Algolia you can add records in three different ways; manually, by file upload or via the API.\n\nWe'll be using the API which is why we needed to add our Admin Key in our Cockpit configuration.\n\n## Adding Our Posts to Algolia\n\nIf you head back over to Cockpit and click on the menu you should see a new item under 'DETEKTIVO' called Manage Index, click on it.\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Manage Index](\u002Fimages\u002Fposts\u002F5c77fca8e0e8acockpit-manage-index.png)\n\u003C\u002Fdiv>\n\nYou'll see your posts collection is listed because you added it in config.yaml. The number 4 refers to the number of fields that will be indexed. (This will be 3 if you just have title, title_slug and excerpt).\n\nIf you now click the refresh icon to Re-Index the posts they will become visible in Algolia.\n\nYou can edit the configuration for this index by going to - `www.algolia.com\u002Fapps\u002F\u003CYOUR-APP-ID>\u002Fexplorer\u002Fconfiguration\u002Fposts\u002Fsearchable-attributes`\n\nHere you can add things like searchable attributes, rankings and set up result highlighting.\n\nYou may wish to update the Search behavior > Retrieved attributes to be just the title and title_slug so that the response will be smaller and easier to read.\n\nNow that we have some records in Algolia that can be searched we can make a GET request using Postman or Insomnia or even by just visiting the URL in the browser.\n\nThe endpoint we'll be using with Detektivo will be `cms.yourdomain.com\u002Fapi\u002Fdetektivo\u002Fcollection\u002Fposts?token=\u003CCOCKPIT-SEARCH-API-KEY>&q={searchterm}`\n\nWhere `\u003CCOCKPIT-SEARCH-API-KEY>` is a key we've yet to create.\n\nThe great thing about the Detektivo addon is that each time you add\u002Fupdate\u002Fdelete a post it automatically updates our posts index at Algolia for us.\n\n## Adding a Search-Only API Key in Cockpit\n\nWe need to create a new API key in Cockpit however we need to make sure it only has permissions to perform searches on our posts collection and nothing else. That is because the key will be public and exposed in each request made.\n\nWe `MUST NOT` use our MASTER API-KEY or any other Key we've previously created in Cockpit.\n\nSo head over to Settings then API Access in Cockpit and click the little plus icon to generate a new API Key.\n\nMake sure to add `\u002Fapi\u002Fdetektivo\u002Fcollection\u002Fposts` in the rules section like so:\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Search-Only API Key](\u002Fimages\u002Fposts\u002F5c77f61a3157dcockpit-search-only-api-key.png)\n\u003C\u002Fdiv>\n\nThis rule means only requests made to that endpoint with the key will be authorised.\n\nNow if you make a get request to the endpoint mentioned above with a search term you know exists in the title of one of your posts you should see some results returned.\n\n## Updating Our Blogs Environment Variables\n\nOpen up your .env file for Nuxt and add a new variable called SEARCH_URL.\n\n```\nSEARCH_URL=https:\u002F\u002Fcms.yourdomain.com\u002Fapi\u002Fdetektivo\u002Fcollection\u002Fposts?token=*COCKPIT-SEARCH-API-KEY*&q=\n```\n\nNow we also need to update our nuxt.config.js and add an env property. Add the following anywhere inside module.exports = { ... }\n\n```javascript\nenv: {\n  searchUrl: process.env.SEARCH_URL\n},\n```\n\nThe reason we need to do this is because we will be making requests to our searchUrl on the client side which means we need to have this variable bundled up in our js files.\n\n\u003Cdiv class=\"blog-note\">\n    Warning: Do not add any secret or sensitive details\u002Fkeys to the env property as they will be publicly exposed in our js files\n\u003C\u002Fdiv>  \n\nNow we will be able to access the searchUrl variable even after our site has been generated. Don't worry, the token we are using is our Search-Only Key so nobody will be able to delete or edit our posts etc.\n\nMake sure you also update your create-env.js if deploying to Netlify.\n\n```javascript\nconst fs = require('fs')\nfs.writeFileSync('.\u002F.env', `\nBASE_URL=${process.env.BASE_URL}\\n\nPOSTS_URL=${process.env.POSTS_URL}\\n\nURL=${process.env.URL}\\n\nPER_PAGE=${process.env.PER_PAGE}\\n\nSEARCH_URL=${process.env.SEARCH_URL}\n`)\n```\n\n## Adding a New Search Page\n\nFirst we'll just update our PageNav.vue component to add a link to the new page:\n\n```html\n\u003Ctemplate>\n  \u003Cnav class=\"text-center my-4\">\n    \u003Ca href=\"\u002F\" class=\"p-2 text-sm sm:text-lg inline-block text-gray-800 hover:underline\">Blog\u003C\u002Fa>\n    \u003Ca href=\"\u002Fabout\" class=\"p-2 text-sm sm:text-lg p-2 inline-block text-gray-800 hover:underline\">About\u003C\u002Fa>\n    \u003Ca href=\"\u002Fsearch\" class=\"p-2 text-sm sm:text-lg p-2 inline-block text-gray-800 hover:underline\">Search\u003C\u002Fa>\n  \u003C\u002Fnav>\n\u003C\u002Ftemplate>\n```\n\nIn the pages directory of your blog add a new file called `search.vue` and put the following inside it:\n\n```html\n\u003Ctemplate>\n  \u003Csection class=\"my-8\">\n    \u003Cdiv class=\"text-center\">\n      \u003Ch1 class=\"mb-6\">Search Page\u003C\u002Fh1>\n      \u003Cp>\n        This is a live search example using Algolia and Cockpit!\n      \u003C\u002Fp>\n\n      \u003Cdiv class=\"my-8\">\n\n        \u003Cinput type=\"text\" name=\"searchTerm\" v-model=\"searchTerm\" placeholder=\"Search Posts...\" class=\"text-center block mt-2 bg-gray-200 rounded w-full py-2 px-3\">\n\n        \u003Cdiv v-if=\"results.length !==0\" class=\"search-results\">\n          \u003Ca v-for='result in results' :key=\"result.title_slug\" :href=\"'\u002F'+result.title_slug\" class=\"block text-gray-800 p-3 text-left\">\n            {{ result.title }}\n          \u003C\u002Fa>\n        \u003C\u002Fdiv>\n\n        \u003Cdiv v-else-if=\"searchTerm.length >= 3\">\n          \u003Cspan class=\"block text-gray-800 p-3 text-left\">\n            No results found\n          \u003C\u002Fspan>\n        \u003C\u002Fdiv>\n\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n```javascript\n\u003Cscript>\nimport axios from 'axios';\n\nexport default {\n  data: function () {\n    return {\n      searchTerm: '',\n      results:[]\n    }\n  },\n\n  watch: {\n    searchTerm: 'search'\n  },\n\n  methods: {\n    search() {\n      if(this.searchTerm.length \u003C 3){\n        return this.results = []\n      }\n\n      axios.get(process.env.searchUrl+this.searchTerm)\n      .then(response => {\n        this.results = response.data.hits\n      })\n    }\n  }\n}\n\u003C\u002Fscript>\n```\n\nSo what we're doing here is simply telling Nuxt to watch the searchTerm variable and to call the search method when it changes. If it has a length longer than 2 we will make a call to Cockpit to fetch the search results.\n\nThese results are then displayed and they use the title_slug as the url for the link.\n\n## Adding Highlighting for Results\n\nTo improve these results we could add highlighting by changing `{{ result.title }}` to:\n\n```html\n\u003Cspan v-html=\"result._highlightResult.title.value\">\u003C\u002Fspan>\n```\n\nTo make this work you first need to go to Algolia and add the title to `Attributes to highlight` in Pagination and Display > Highlighting.\n\nThis will return the highlighted title word(s) wrapped in `\u003Cem>\u003C\u002Fem>` tags by default. That is why we need to use v-html otherwise the em tags would simply be rendered as a string.\n\nYou could then add a simple css rule to give the em tag a nice background colour for highlighting.\n\nYou can add something like this to main.css\n\n```css\n\u002F* purgecss ignore *\u002F\n.search-results em {\n  @apply not-italic bg-blue-200;\n}\n```\n\nI've added a purgecss ignore comment here to make sure this css isn't removed when we build the site because `.search-results em` will not actually exist at build time as it is only present if we search on the client side so the css would be removed otherwise.\n\nWe could also add highlighting for the post excerpt. However the excerpt may be too long so we don't want to display the whole thing in the results. \n\n## Snippeting The Post Excerpt\n\nAlgolia offers a feature called snippeting that allows us to only display a snippet of text around the matched word(s).\n\nIf you visit Attributes to snippet in Algolia and add `excerpt` then you can update the html to the following:\n\n```html\n\u003Ctemplate>\n  \u003Csection class=\"my-8\">\n    \u003Cdiv class=\"text-center\">\n      \u003Ch1 class=\"mb-6\">Search Page\u003C\u002Fh1>\n      \u003Cp>\n        This is a live search example using Algolia and Cockpit!\n      \u003C\u002Fp>\n\n      \u003Cdiv class=\"my-8\">\n\n        \u003Cinput type=\"text\" name=\"searchTerm\" v-model=\"searchTerm\" placeholder=\"Search Posts...\" class=\"text-center block mb-4 shadow text-gray-600 rounded w-full py-2 px-3\">\n\n        \u003Cdiv v-if=\"results.length !==0\" class=\"search-results\">\n          \u003Ca v-for='result in results' :key=\"result.title_slug\" :href=\"'\u002F'+result.title_slug\" class=\"block text-gray-800 p-3 text-left\">\n            \u003Cspan v-html=\"result._highlightResult.title.value\" class=\"block font-bold mb-1\">\u003C\u002Fspan>\n            \u003Cspan v-html=\"result._snippetResult.excerpt.value\">\u003C\u002Fspan>\n          \u003C\u002Fa>\n        \u003C\u002Fdiv>\n\n        \u003Cdiv v-else-if=\"searchTerm.length >= 3\">\n          \u003Cspan class=\"block text-gray-800 p-3 text-left\">\n            No results found\n          \u003C\u002Fspan>\n        \u003C\u002Fdiv>\n\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n\nYou should now see something like this:\n\n\u003Cdiv class=\"blog-image\">\n\n![Static Blog Search](\u002Fimages\u002Fposts\u002F5c79010acc040static-blog-search.png)\n\u003C\u002Fdiv>\n\nWith title and excerpt highlighting and also snippeting for the post excerpt.\n\nYou can check out the GitHub repo of the finished blog [here](https:\u002F\u002Fgithub.com\u002Fwillbrowningme\u002Fnuxt-cockpit-static-blog) and see a live demo of the site on Netlify here - [https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com](https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com\u002F)","\u002Fimages\u002Fposts\u002F5c79010acc040static-blog-search.png",1551343924,1569937274,{"title":44,"title_slug":45,"tags":46,"meta_description":47,"content":48,"image":49,"_created":50,"_modified":51},"Building a Static Blog with Nuxt.js and Cockpit Headless CMS - Part 4: Post Pagination","building-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-4-post-pagination",[18,19],"This is just an additional post for our Nuxt Cockpit series looking at handling pagination for our blog posts when we statically generate our site.","If you haven't read Parts 1, 2, and 3 of this guide you can find them here:\n- [Part 1: Setup](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-1-setup\u002F)\n- [Part 2: Dynamic Routes](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-2-dynamic-routes)\n- [Part 3: Deployment](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-3-deployment\u002F)\n\n## Adding Pagination to our Blog\n\nOpen up your .env file and add the following variable to it \n\n```env\nPER_PAGE=2\n```\n\nWe're setting it low on purpose so we can easily see the pagination in action. \n\nThen at the top of nuxt.config.js add this line:\n\n```javascript\nconst perPage = Number(process.env.PER_PAGE)\n```\n\nNow that we have our perPage variable we can update our generate: property by adding the following just below `let posts = ...`\n\n```javascript\nif(perPage \u003C data.total) {\n  let pages = collection\n  .take(perPage-data.total)\n  .chunk(perPage)\n  .map((items, key) => {\n    let currentPage = key + 2\n    \n    return {\n      route: `blog\u002F${currentPage}`,\n      payload: {\n        posts: items.all(),\n        hasNext: data.total > currentPage*perPage\n      }\n    }\n  }).all()\n\n  return posts.concat(tags,pages)\n}\n```\n\nSo breaking this down, first we check if the value we have set to display per page is less than the total number of blog posts.\n\nIf it is less and for example we have set 10 posts per page but there are 25 posts in total. Then with the `take` method we take (10 - 25) which equals -15 posts. The negative integer means we want to take 15 posts from the end of the posts collection. More information on this is in the [collectjs docs](https:\u002F\u002Fgithub.com\u002Fecrmnn\u002Fcollect.js\u002F#take).\n\nThe reason we only want to take from the end of the collection is because we do not want to include the first page of posts as this is currently already set as our blog's home page. (We already have 10 posts on the home page that we don't need to include for pagination)\n\nNext we chunk the 15 posts we've got by the `perPage` variable, so we would have 10 and 5 in two chunks.\n\nThen we simply map these items into their respective pages, where `currentPage` is the key that we add 2 onto since the first chunk will have a key of 0 however we want this to effectively be our page 2 (as we're going to count our home page as page 1).\n\nWe pass the post items in each chunk as the payload to use and we also pass a `hasNext` variable that lets us know if there is another page or not. In our example here `data.total` is 25 as there are 25 posts in total. When we're in the second chunk that contains 5 posts the  chunk key will be 1 so we have (1 + 2)*10 which is 30. So `hasNext` will evaluate to false.\n\n## Adding the Blog Page\n\nWe're going to set our blog up so that pages are found at `yourdomain.com\u002Fblog\u002F2` etc. You can instead do `yourdomain.com\u002F2`, `yourdomain.com\u002Fblog\u002Fpage-2` or whatever you prefer.\n\nIn the pages directory create a new folder called `blog` and add a file named `_page.vue` to it. Put the following code inside:\n\n```html\n\u003Ctemplate>\n  \u003Csection>\n    \u003Cdiv class=my-8>\n      \u003Ch1 class=\"mb-6\">Blog Page {{ page }}\u003C\u002Fh1>\n      \u003Cul class=\"flex flex-col w-full p-0\">\n        \u003Cli class=\"mb-6 w-full\" v-for=\"(post, key) in posts\" :key=\"key\">\n          \u003Cdiv class=\"text-gray-600 font-bold text-sm tracking-wide\">\n            {{ post._created | toDate }}\n            \u003Ca v-for=\"(tag, key) in post.tags\" :key=\"key\" :href=\"'\u002Fcategory\u002F'+tag\" class=\"ml-1\">{{ tag }}\u003C\u002Fa>\n          \u003C\u002Fdiv>\n\n          \u003Ca :href=\"'\u002F'+post.title_slug\">\n            \u003Ch2 class=\"my-2 text-gray-800 text-lg lg:text-xl font-bold\">\n              {{ post.title }}\n            \u003C\u002Fh2>\n          \u003C\u002Fa>\n\n          \u003Cdiv class=\"page-content hidden md:block text-base mb-2\" v-html=\"post.excerpt\">\n          \u003C\u002Fdiv>\n          \u003Ca class=\"text-sm text-blue-400\" :href=\"'\u002F'+post.title_slug\">\n            Read more\n          \u003C\u002Fa>\n        \u003C\u002Fli>\n      \u003C\u002Ful>\n      \u003Cdiv class=\"flex justify-center mt-8\">\n        \u003Ca :href=\"page === '2' ? '\u002F' : `\u002Fblog\u002F${Number(page)-1}`\" class=\"text-sm pr-2\">\n          Previous Page\n        \u003C\u002Fa>\n        \u003Ca v-if=\"hasNext\" :href=\"`\u002Fblog\u002F${Number(page)+1}`\" class=\"text-sm pl-2\">\n          Next Page\n        \u003C\u002Fa>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n\n```javascript\n\u003Cscript>\nexport default {\n  async asyncData ({ app, params, error, payload }) {\n    if (payload) {\n      return { posts: payload.posts, page: params.page, hasNext: payload.hasNext }\n    } else {\n      let { data } = await app.$axios.post(process.env.POSTS_URL,\n      JSON.stringify({\n          filter: { published: true },\n          limit: process.env.PER_PAGE,\n          skip: (params.page-1)*process.env.PER_PAGE,\n          sort: {_created:-1},\n          populate: 1\n        }),\n      {\n        headers: { 'Content-Type': 'application\u002Fjson' }\n      })\n\n      if (!data.entries[0]) {\n        return error({ message: '404 Page not found', statusCode: 404 })\n      }\n\n      return { posts: data.entries, page: params.page, hasNext: Number((params.page-1)*process.env.PER_PAGE) + Number(process.env.PER_PAGE) \u003C data.total }\n    }\n  },\n  head () {\n    return {\n      title: `Nuxt Cockpit Static Blog - Page ${this.page}`\n    }\n  }\n}\n\u003C\u002Fscript>\n```\n\nNotice the limit and skip options we added when fetching the posts for the dev server.\n\nWhen our blog has been generated it will be using the payload we passed through in nuxt.config.js.\n\nWe do a quick check to see if the current page is 2 when rendering the previous link as we don't want to link to `yourdomain.com\u002Fblog\u002F1` as that page doesn't exist, we want to simply go back to the home page to display our first page of posts.\n\nHead over to index.vue in pages and update that too so we have a next page if there is one available.\n\n```html\n\u003Ctemplate>\n  \u003Csection>\n    \u003Cdiv class=my-8>\n      \u003Cul class=\"flex flex-col w-full p-0\">\n        \u003Cli class=\"mb-6 w-full\" v-for=\"(post, key) in posts\" :key=\"key\">\n          \u003Cdiv class=\"text-gray-600 font-bold text-sm tracking-wide\">\n            {{ post._created | toDate }}\n            \u003Ca v-for=\"tag in post.tags\" :key=\"tag\" :href=\"'\u002Fcategory\u002F'+tag\" class=\"ml-1\">{{ tag }}\u003C\u002Fa>\n          \u003C\u002Fdiv>\n\n          \u003Ca :href=\"'\u002F'+post.title_slug\">\n            \u003Ch2 class=\"my-2 text-gray-800 text-lg lg:text-xl font-bold\">\n              {{ post.title }}\n            \u003C\u002Fh2>\n          \u003C\u002Fa>\n\n          \u003Cdiv class=\"page-content hidden md:block text-base mb-2\" v-html=\"post.excerpt\">\n          \u003C\u002Fdiv>\n          \u003Ca class=\"text-sm text-blue-400\" :href=\"'\u002F'+post.title_slug\">\n            Read more\n          \u003C\u002Fa>\n        \u003C\u002Fli>\n      \u003C\u002Ful>\n      \u003Cdiv v-if=\"hasNext\" class=\"flex justify-center mt-8\">\n        \u003Ca href=\"\u002Fblog\u002F2\" class=\"text-sm\">\n          Next Page\n        \u003C\u002Fa>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n\n```javascript\n\u003Cscript>\nexport default {\n  async asyncData ({ app, error }) {\n    const { data } = await app.$axios.post(process.env.POSTS_URL,\n    JSON.stringify({\n        filter: { published: true },\n        limit: process.env.PER_PAGE,\n        sort: {_created:-1},\n        populate: 1\n      }),\n    {\n      headers: { 'Content-Type': 'application\u002Fjson' }\n    })\n\n    if (!data.entries[0]) {\n      return error({ message: '404 Page not found', statusCode: 404 })\n    }\n\n    return { posts: data.entries, hasNext: process.env.PER_PAGE \u003C data.total }\n  }\n}\n\u003C\u002Fscript>\n```\n\nNotice we've added the limit option when fetching our posts which is set to our `PER_PAGE` environment variable.\n\nIf you visit the site now you should see the home page with two posts and a next link. If you click next you'll be taken to `\u002Fblog\u002F2` and depending on how many posts you've got in Cockpit you'll see a previous and next link on this page.\n\n\u003Cdiv class=\"blog-image\">\n\n![Nuxt Pagination](\u002Fimages\u002Fposts\u002F5be984f90c881nuxt-pagination.png)\n\u003C\u002Fdiv>\n\n## Update our Netlify Script\n\nWe also need to remember to update our `create-env.js` file for Netlify.\n\n```javascript\nconst fs = require('fs')\nfs.writeFileSync('.\u002F.env', `\nAPI_TOKEN=${process.env.API_TOKEN}\\n\nBASE_URL=${process.env.BASE_URL}\\n\nPOSTS_URL=${process.env.POSTS_URL}\\n\nURL=${process.env.URL}\\n\nPER_PAGE=${process.env.PER_PAGE}\n`)\n```\n\nMake sure to update your environment variables when you are logged into Netlify like we did in Part 3 so that `PER_PAGE` is included.\n\n## Updating our Sitemap\n\nWe also need to update our sitemap otherwise it won't be aware of our new blog pages so open up nuxt.config.js and update it to the following:\n\n```javascript\nsitemap: {\n  path: '\u002Fsitemap.xml',\n  hostname: process.env.URL,\n  cacheTime: 1000 * 60 * 15,\n  generate: true, \u002F\u002F Enable me when using nuxt generate\n  async routes () {\n    let { data } = await axios.post(process.env.POSTS_URL,\n    JSON.stringify({\n        filter: { published: true },\n        sort: {_created:-1},\n        populate: 1\n      }),\n    {\n      headers: { 'Content-Type': 'application\u002Fjson' }\n    })\n\n    const collection = collect(data.entries)\n\n    let tags = collection.map(post => post.tags)\n    .flatten()\n    .unique()\n    .map(tag => `category\u002F${tag}`)\n    .all()\n\n    let posts = collection.map(post => post.title_slug).all()\n\n    if(perPage \u003C data.total) {\n      let pages = collection\n      .take(perPage-data.total)\n      .chunk(perPage)\n      .map((items, key) => `blog\u002F${key+2}`)\n      .all()\n\n      return posts.concat(tags,pages)\n    }\n\n    return posts.concat(tags)\n  }\n},\n```\n\nNow at the moment we only have pagination set up for our blog posts from all categories. If we wanted to go further we could also set up pagination per category to something like `yourdomain.com\u002Fcategory\u002Fnuxt\u002F2` etc.\n\nUpdate your .env `PER_PAGE` variable to something sensible like 10 and you should be good to go!\n\nYou can check out the GitHub repo of the finished blog [here](https:\u002F\u002Fgithub.com\u002Fwillbrowningme\u002Fnuxt-cockpit-static-blog) and see a live demo of the site on Netlify here - [https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com](https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com\u002F)","\u002Fimages\u002Fposts\u002F5be984f90c881nuxt-pagination.png",1542024917,1569938840,{"title":53,"title_slug":54,"tags":55,"meta_description":57,"content":58,"image":59,"_created":60,"_modified":61},"Building a Simple Referral System in Laravel","building-a-simple-referral-system-in-laravel",[56],"laravel","In this post we'll carry on from the previous one regarding sharing cookies with a subdomain and use what we learned to build a simple referral system to track who has referred users when they register on the site.","This post is a continuation of [Sharing Cookies with Subdomains in Laravel](https:\u002F\u002Fwillbrowning.me\u002Fsharing-cookies-with-subdomains-in-laravel\u002F) so if you have not read that please go and check it out.\n\n## Adding Authentication to our Subdomain\n\nIn the example we're making here I'll be adding authentication to `app.example.test` and we'll be treating `example.test` as the marketing frontend for our application.\n\nSo let's edit our Homestead.yaml file and add a database we can use for our authentication.\n\n```yaml\ndatabases:\n    - example\n```\n\nThen run `homestead up --provision` or `homestead reload --proivision` if homestead is already running.\n\nNow we need to ssh into homestead so run `homestead ssh` and navigate to the directory where `app.example.test` is located. Then run the following:\n\n```bash\nphp artisan make:auth\n```\n\nYou should now be able to see the login and register pages.\n\n\u003Cdiv class=\"blog-image\">\n    \n![Register Example](\u002Fimages\u002Fposts\u002F5bd2eaf94690bregister-example.png) \n\u003C\u002Fdiv>\n\n## Creating Middleware to Set our Cookie\n\nIn our `example.test` code create some new Middleware called `CheckReferral`.\n\n```bash\nphp artisan make:middleware CheckReferral\n```\n\nOpen up the newly created file and edit the handle function.\n\n```php\npublic function handle($request, Closure $next)\n{\n\tif( !$request->hasCookie('referral') && $request->query('ref') ) {\n        return redirect($request->url())->withCookie(cookie()->forever('referral', $request->query('ref')));\n    }\n\n    return $next($request);\n}\n```\n\nWhat we are doing here is checking whether a cookie named `referral` is currently set. If it is not and the request contains a query parameter `ref` then Laravel will set a cookie named referral with the value of whatever ref is equal to that has the maximum expiry time.\n\nFor example the url `example.test\u002F?ref=laravel` would set a cookie (if none already exists) with value `laravel`.\n\nIf we want this middleware to run during every web route HTTP request to our application then we can add it to our middleware by editing `app\u002FHttp\u002FKernel.php` and adding it to the 'web' section of the $middlewareGroups property like so:\n\n```php\nprotected $middlewareGroups = [\n    'web' => [\n        \\App\\Http\\Middleware\\EncryptCookies::class,\n        \\Illuminate\\Cookie\\Middleware\\AddQueuedCookiesToResponse::class,\n        \\Illuminate\\Session\\Middleware\\StartSession::class,\n        \u002F\u002F \\Illuminate\\Session\\Middleware\\AuthenticateSession::class,\n        \\Illuminate\\View\\Middleware\\ShareErrorsFromSession::class,\n        \\App\\Http\\Middleware\\VerifyCsrfToken::class,\n        \\Illuminate\\Routing\\Middleware\\SubstituteBindings::class,\n        \\App\\Http\\Middleware\\CheckReferral::class,\n    ],\n\n    'api' => [\n        'throttle:60,1',\n        'bindings',\n    ],\n];\n```\n\nIf you only wanted to check and set the cookie on the homepage you could simply add it to the $routeMiddleware property instead and then call it for the `'\u002F'` route in web.php.\n\n## Checking our Cookie is Being Set\n\nIf you remember from the previous post we had a route `\u002Fcookie` to check if the cookie had been set. Let's edit web.php and update this:\n\n```php\nRoute::get('\u002F', function () {\n    \u002F\u002FCookie::queue(Cookie::make('test', '123', 60));\n\n    return view('welcome');\n});\n\nRoute::get('\u002Fcookie', function () {\n    return Cookie::get('referral');\n});\n```\n\nMake sure to comment out or delete the Cookie::queue we added in the previous post as we don't need this anymore.\n\nNow if we visit `example.test\u002F?ref=laravel` you'll notice we're redirected to `example.test`.\n\nIf we go to `example.test\u002Fcookie` then you should see the value laravel returned.\n\nOur cookie is currently being encrypted by Laravel but since it does not contain sensitive data lets disable it by editing `app\u002FHttp\u002FMiddleware\u002FEncryptCookies.php`.\n\n```php\nprotected $except = [\n     'referral'\n ];\n```\n\nMake sure to update `EncryptCookies.php` for app.example.test too.\n\n## Registering a User and Generating a Referral ID\n\nHead over to your code for `app.example.test` and then register a new user in your browser.\n\nLogin with your newly created user. We'll be using a packaged called [hashids](https:\u002F\u002Fgithub.com\u002Fvinkla\u002Flaravel-hashids) that has been ported to Laravel to create a short unique string based on our users' ID in the database.\n\nSo install the package by running the following:\n\n```bash\ncomposer require vinkla\u002Fhashids\n```\n\nIt should be discovered automatically. Next add the Facade to our aliases list at the bottom of config\u002Fapp.php\n\n```php\n'Hashids' => Vinkla\\Hashids\\Facades\\Hashids::class,\n```\n\nNow we can publish the vendor files by running:\n\n```php\nphp artisan vendor:publish --provider=\"Vinkla\\Hashids\\HashidsServiceProvider\"\n```\n\nOpen up `config\u002Fhashids.php` and update the 'main' connection. You can use laravel to generate a random string for the salt, just temporarily add `dd(str_random(40));` to any route in web.php.\n\n```php\n'main' => [\n    'salt' => 'yGPMa8oZc7PEJXxEnOIAhZscjujizzCPt028vCSG',\n    'length' => 6,\n],\n```\n\nNow we will be able to generate a unique 6 character long referral ID for each user based on their ID in the database.\n\nCreate a new route in web.php called `referral-link`.\n\n```php\nRoute::get('\u002Freferral-link', 'HomeController@referral');\n```\n\nWe are using the HomeController generated by Laravel's auth scaffolding as it already has the auth middleware.\n\nEdit HomeController.php:\n\n```php\npublic function referral()\n{\n    return 'http:\u002F\u002Fexample.test\u002F?ref=' . \\Hashids::encode(auth()->user()->id);\n}\n```\n\nYou should see something like this `http:\u002F\u002Fexample.test\u002F?ref=V53YMO` returned.\n\n## Checking for the Cookie When Registering New Users\n\nFirst let's create a new migration to add a new column to our database.\n\n```bash\nphp artisan make:migration add_referred_by_column_to_users_table --table=users\n```\n\nEdit the new migration file in `database\u002Fmigrations` \n\n```php\npublic function up()\n{\n    Schema::table('users', function (Blueprint $table) {\n        $table->unsignedInteger('referred_by')->nullable()->after('email');\n    });\n}\n```\n\nThen whilst inside Homestead and in the correct directory run `php artisan migrate`.\n\nThere will now be a referred_by column right after the email column in the users table.\n\nNow we just need to edit `app\u002FHttp\u002FControllers\u002FAuth\u002FRegisterController.php` so we can save the referred by data.\n\n```php\nuse Illuminate\\Support\\Facades\\Cookie;\n```\n\nMake sure to add that to the top of the file first, then update the create function:\n\n```php\nprotected function create(array $data)\n{\n    $cookie = Cookie::get('referral');\n\n\t$referred_by = $cookie ? \\Hashids::decode($cookie)[0] : null;\n\n    return User::create([\n        'name' => $data['name'],\n        'email' => $data['email'],\n        'password' => Hash::make($data['password']),\n        'referred_by' => $referred_by\n    ]);\n}\n```\n\nWe check if the cookie named `referral` is set (if it is not then null is returned) then we use our Hashids package to decode the value in the cookie and give us the ID of the user who referred this new registration.\n\nHashids::decode() returns an array which is why we have to add [0].\n\nBefore we continue make sure to update `app\u002FUser.php` to add 'referred_by' to the $fillable property.\n\n```php\nprotected $fillable = [\n    'name', 'email', 'password', 'referred_by'\n];\n```\n\n## Testing it out With a New User Registration\n\nBefore you log out of the current user visit `app.example.test\u002Freferral-link` and copy your referral link.\n\nThen log out and make sure to clear all your cookies for both `example.test` and `app.example.test`. Then paste your referral link into the browser (e.g. [http:\u002F\u002Fexample.test\u002F?ref=V53YMO](http:\u002F\u002Fexample.test\u002F?ref=V53YMO)).\n\nThen imagine that we click a button on `example.test` that takes us to `app.example.test\u002Fregister` for us to sign up for the application.\n\nEnter details for a new user and click `Register`. If you now check out the records in the database table you should see the referred_by column for this new user contains the id of the first user you created.\n\nWe can create a relationship that returns users who you have referred.\n\nUpdate `app\u002FUser.php` and add the following to the bottom of the file.\n\n```php\npublic function referrer()\n{\n    return $this->belongsTo('App\\User', 'referred_by');\n}\n\npublic function referrals()\n{\n    return $this->hasMany('App\\User', 'referred_by');\n}\n```\n\nThen update your web.php routes file.\n\n```php\nRoute::get('\u002Freferrer', 'HomeController@referrer');\nRoute::get('\u002Freferrals', 'HomeController@referrals');\n```\n\nAnd finally HomeController.php\n\n```php\npublic function referrer()\n{\n    return auth()->user()->referrer;\n}\n\npublic function referrals()\n{\n    return auth()->user()->referrals;\n}\n```\n\nNow if you login as your first user and visit `app.example.test\u002Freferrals` you'll see an array of all the users who you've referred to the site.\n\nIf you visit `app.example.test\u002Freferrer` you'll see the details of the user who referred you to the site.\n\nObviously we would never do this in a production application but things like `auth()->user()->referrals()->count()` could be useful.\n\n## Closing Thoughts\n\nThis is only a very simple example but hopefully it gives you a basic idea of how a more complex system could be implemented. If you are using the same domain for marketing and registrations then you can skip all the cookie sharing stuff and keep your cookies encrypted.\n\nSource code for both sites can be found here [https:\u002F\u002Fgithub.com\u002Fwillbrowningme\u002Flaravel-user-referral-example](https:\u002F\u002Fgithub.com\u002Fwillbrowningme\u002Flaravel-user-referral-example).","\u002Fimages\u002Fposts\u002F5bd2eaf94690bregister-example.png",1540549429,1565290381,{"title":63,"title_slug":64,"tags":65,"meta_description":66,"content":67,"image":68,"_created":69,"_modified":70},"Sharing Cookies with Subdomains in Laravel","sharing-cookies-with-subdomains-in-laravel",[56],"Sometimes it can be useful for a subdomain such as app.example.com to have access to the cookies that are set by example.com. A situation where this could be used would be for a very simple referral tracking system, where example.com sets a cookie if there is a query string present in the URL. Then when the user registers on app.example.com this cookie is retrieved and the data regarding who referred that user is stored in the database.","## Setting up Our Example Sites\n\nFor this example I'll be using [Laravel Homestead](https:\u002F\u002Flaravel.com\u002Fdocs\u002F5.7\u002Fhomestead) to set up a couple of local Laravel sites.\n\n```bash\nlaravel new example && laravel new subdomain\n```\n\nThen we need to edit Homestead.yaml and our hosts file to add addresses for these applications.\n\n```yaml\nsites:\n    - map: example.test\n      to: \u002Fhome\u002Fvagrant\u002Fcode\u002Fexample\u002Fpublic\u002F\n    - map: app.example.test\n      to: \u002Fhome\u002Fvagrant\u002Fcode\u002Fsubdomain\u002Fpublic\u002F\n```\n\n```txt\n192.168.10.10 example.test\n192.168.10.10 app.example.test\n```\n\n```bash\nhomestead up --provision\n```\n\nWhen you've provisioned Homestead you should see the new Laravel welcome screen when you visit `example.test` and `app.example.test`.\n\n\u003Cdiv class=\"blog-image\">\n    \n![New Laravel App](\u002Fimages\u002Fposts\u002F5bd2d2f5c7477example.test.png) \n\u003C\u002Fdiv>\n\n## Updating our Environment Variables\n\nLet's edit our `.env` file for our main example.test site. Update the following values.\n\n```env\nAPP_URL=http:\u002F\u002Fexample.test\nSESSION_DOMAIN=.example.test\n```\n\nThe `SESSION_DOMAIN` variable is important as this will allow our subdomain to access all cookies set by the parent domain.\n\nNow let's also update the `.env` file for our app.example.test site.\n\n```env\nAPP_URL=http:\u002F\u002Fapp.example.test\n```\n\nWe don't need to set `SESSION_DOMAIN` here.\n\nWe've left `SESSION_DRIVER` as the default value which is file for both sites.\n\n## Saving our Cookie\n\nIn our main `example.test` code update the default welcome route in `web.php`.\n\n```php\nRoute::get('\u002F', function () {\n    Cookie::queue(Cookie::make('test', 'abc', 60));\n\n    return view('welcome');\n});\n\nRoute::get('\u002Fcookie', function () {\n    return Cookie::get('test');\n});\n```\n\nHere we're informing Laravel to set a Cookie named 'test' with a value of 'abc' that will expire in 60 minutes.\n\nFirst visit `example.test` in your browser, then if you visit `example.test\u002Fcookie` you should see the value we set of `abc`. So we know that our Cookie has been succesfully set.\n\n## Accessing our Cookie on our Subdomain\n\nIf you head over to your `app.example.test` code and add the following to the web.php routes file:\n\n```php\nRoute::get('\u002Fcookie', function () {\n    return Cookie::get('test');\n});\n```\nThen visit `app.example.test\u002Fcookie` you won't be able to see anything yet as Laravel by default [encrypts all cookies](https:\u002F\u002Flaravel.com\u002Fdocs\u002F5.7\u002Fresponses#cookies-and-encryption) that are set.\n\nThere are a couple of options we have here on how to access the cookie.\n\n1. **Use the same APP_KEY value for both sites**\n\n\nWe can copy the APP_KEY value from our example.test .env file and paste it in our app.example.test .env file so that they both have the same value. If you try this and then visit again `app.example.test\u002Fcookie` you will be able to see the value `abc` and the cookie can be decrypted succsesfully. \n\nThe reason this works is because Laravel uses our APP_KEY value when encrypting, decrypting and signing data.\n\nSome people may feel uncomfortable having the same APP_KEY value for both sites but there is another way.\n\n2. **Disable encryption for the cookie in question**\n\n\nWe can tell Laravel not to encrypt certain cookies if they do not contain sensitive data.\n\nIn **`both`** of our sites open up the `app\u002FHttp\u002FMiddleware` folder and edit the `EncryptCookies.php` file.\n\n```php\nprotected $except = [\n\t'test'\n];\n```\n\nHere we can add the name of any cookies we don't wish to be encrypted. Make sure you've added this to `both sites`.\n\nChange the value of the cookie set by `example.test` to something else so we can be sure it's working.\n\n```php\nCookie::queue(Cookie::make('test', '123', 60));\n```\n\nThen visit `example.test` in your browser again. Check `example.test\u002Fcookie` and you should see the value of '123' this time.\n\nNow if you visit `app.example.test\u002Fcookie` you should be able to access the cookie and see the value of '123' returned.\n\nYou should only disable encryption for a cookie if it contains non-sensitive information.\n\n## Sharing Cookies for a Simple Referral System\n\nI'll write another post shortly detailing how we can use what we've applied here to create a very simple referral system that tracks who has been referred by who. The system will use middleware to determine whether to set a cookie if a certain query string is present in the request. This will enable you to link to any url on your main site like so `example.test\u002F?ref=referral-id` or `example.test\u002Fpricing?ref=referral-id`.\n\n`Update:` You can find the simple referral system post here - [Building a Simple Referral System in Laravel](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-simple-referral-system-in-laravel\u002F)","\u002Fimages\u002Fposts\u002F5bd2d2f5c7477example.test.png",1540542424,1563372837,{"title":72,"title_slug":73,"tags":74,"meta_description":76,"content":77,"image":78,"_created":79,"_modified":80},"Clearing Your Cloudflare Cache After New Deployments","clearing-your-cloudflare-cache-after-new-deployments",[75],"deployment","I had lost count of the number of times I've previously deployed new code to production, cleared browser cache and still been unable to see the changes. The reason being that Cloudflare was caching these files and still serving the old files. Luckily there is a really easy way to clear the cache and purge everything using the API.","## Getting your Cloudflare API key\n\nFirst we need to find our API key. So visit the \"My Profile\" section when logged into Cloudflare - [https:\u002F\u002Fdash.cloudflare.com\u002Fprofile](https:\u002F\u002Fdash.cloudflare.com\u002Fprofile) at the bottom of the page you'll see your keys.\n\n\u003Cdiv class=\"blog-image\">\n    \n![Cloudflare API keys](\u002Fimages\u002Fposts\u002F5bcef7562b26ccloudflare-api.png) \n\u003C\u002Fdiv>\n\nWe'll be using the Global API Key to clear the cache.\n\n## Finding your sites Zone ID\n\nThe Zone ID for your website can be found in the \"Overview\" section for that site. It will look like this:\n\n\u003Cdiv class=\"blog-image\">\n    \n![Cloudflare Zone ID](\u002Fimages\u002Fposts\u002F5bcef944bce69cloudflare-zone-id.png) \n\u003C\u002Fdiv>\n\n## Sending the API request\n\nThe request we'll be sending to clear the cache looks like this:\n\n```bash\ncurl -X POST \"https:\u002F\u002Fapi.cloudflare.com\u002Fclient\u002Fv4\u002Fzones\u002FYOUR-ZONE-ID\u002Fpurge_cache\" \\\n     -H \"X-Auth-Email: YOUR-CLOUDFLARE-EMAIL\" \\\n     -H \"X-Auth-Key: YOUR-GLOBAL-API-KEY\" \\\n     -H \"Content-Type: application\u002Fjson\" \\\n     --data '{\"purge_everything\":true}'\n```\nWhere `YOUR-CLOUDFLARE-EMAIL` is the email you use to login to Cloudflare. `YOUR-GLOBAL-API-KEY` is the key we found above and where `YOUR-ZONE-ID` is a unique identifier for your Cloudflare website.\n\nYou can add this code to the end of your deployment script to make sure the cache is purged after each deployment.\n\nIn the above example we're telling Cloudflare to purge everything but you can also choose which items to purge that have matching Cache-Tag headers or which hosts to purge although this does appear to be `only available for Enterprise accounts`.\n\n```bash\n--data '{\n\"tags\":[\"some-tag\",\"another-tag\"],\n\"hosts\":[\"www.example.com\",\"images.example.com\"]\n}'\n```\n\nMore documentation can be found here - [https:\u002F\u002Fapi.cloudflare.com\u002F#zone-purge-files-by-cache-tags-or-host](https:\u002F\u002Fapi.cloudflare.com\u002F#zone-purge-files-by-cache-tags-or-host)\n\nIf everything went to plan you should get a response from Cloudflare like so:\n\n```bash\n{\n  \"success\": true,\n  \"errors\": [],\n  \"messages\": [],\n  \"result\": {\n    \"id\": \"9a7806061c88ada191ed06f989cc3dac\"\n  }\n}\n```","\u002Fimages\u002Fposts\u002F5bcef7562b26ccloudflare-api.png",1533658236,1563372848,{"title":82,"title_slug":83,"tags":84,"meta_description":85,"content":86,"image":87,"_created":88,"_modified":89},"Reducing the Vendor Bundle Size in Nuxt.js","reducing-the-vendor-bundle-size-in-nuxt-js",[18],"When running Nuxt's \"npm run generate\" command I kept getting a warning stating that the javascript vendor bundle was too big on my static blog and that it was above the recommended 300kB size limit. Let's have a look at trying to reduce the size of it!","The warning in question looks like this:\n\n\u003Cdiv class=\"blog-image\">\n    \n![Vendor Warning](\u002Fimages\u002Fposts\u002F5b3b8772f2991vendor-warning-nuxt.png) \n\u003C\u002Fdiv>\n\nOur vendor bundle is coming in at 752kB!\n\n## Identifying the Main Culprits\n\nFirst things first we need to find out why our vendor bundle is so big in the first place.\n\nLuckily Nuxt uses [webpack-bundle-analyzer](webpack-bundle-analyzer) so we can simply add the following to our `nuxt.config.js` under the build property.\n\n```javascript\nbuild: {\n  analyze: true,\n}\n```\n\nThen when you run `npm run generate` it will open up the build analyser at `http:\u002F\u002F127.0.0.1:8888`.\n\n\u003Cdiv class=\"blog-image\">\n    \n![Build Analyser](\u002Fimages\u002Fposts\u002F5b3b8641cc701build-analyser.png) \n\u003C\u002Fdiv>\n\nLooking at this we can see the `highlight.js` package is very big with a parsed size of 540kB!\n\nIf we inspect this further we can see this is mainly due to a few languages included with it like mathematica.js or sqf.js.\n\nNow in my case I only use a handful of common languages so I don't need any of the other included ones.\n\n## Including Only Highlight.js Languages We Need\n\nAfter a little bit of searching I came across [this comment](https:\u002F\u002Fgithub.com\u002Fisagalaev\u002Fhighlight.js\u002Fissues\u002F1257#issuecomment-254504876) on GitHub on how to acheive this.\n\nSo let's give it a try and update our Nuxt site.\n\nI have a `filters.js` in the plugins directory where I was importing highlight.js.\n\nI updated the import to the following: (I was previously doing `import hljs from 'highlight.js'`)\n\n```javascript\nimport hljs from 'highlight.js\u002Flib\u002Fhighlight.js'\n```\nThen simply specify which languages you want to register like so:\n\n```javascript\nhljs.registerLanguage('php', require('highlight.js\u002Flib\u002Flanguages\u002Fphp'))\nhljs.registerLanguage('javascript', require('highlight.js\u002Flib\u002Flanguages\u002Fjavascript'))\nhljs.registerLanguage('css', require('highlight.js\u002Flib\u002Flanguages\u002Fcss'))\n```\n\nMake sure to only add the languages that you intend to use.\n\n## Also Update Highlight.js In Build > Vendor\n\nI also had `Highlight.js` in the vendor file importing all of the languages, so I simply updated this aswell:\n\n```javascript\nbuild: {\n  vendor: ['axios', 'highlight.js\u002Flib\u002Fhighlight.js'],\n  analyze: true,\n}\n```\n\n\u003Cdiv class=\"blog-note\">\n    \u003Cb>Note: \u003C\u002Fb> The vendor array has been deprecated in Nuxt 2.0\n\u003C\u002Fdiv>\n\nThen ran `npm run generate` again and had a look at the build analyser.\n\n## The Result\n\nThis time the vendor bundle was only 226kB in parsed size, down from 752kB! That's a 70% decrease just from removing uneeded languages. \n\n\u003Cdiv class=\"blog-image\">\n    \n![Build Analyser](\u002Fimages\u002Fposts\u002F5b3b8b4fd4f28build-analyser-after.png) \n\u003C\u002Fdiv>\n\nAs you can see we reduced the Highlight.js package down to 31.53kB from 540kB.\n\nAnd since our vendor bundle is now less than 300kB we no longer get the annoying warning in our terminal. `Success`!\n\nAnother example we can use is with moment.js, which is usually overkill if you are only doing some basic date formatting.\n\nAnother library that is much more lightweight and has a similar API is [day.js](https:\u002F\u002Fgithub.com\u002Fiamkun\u002Fdayjs).\n\nIf we remove moment.js and run `npm install dayjs --save` to replace it with day.js then we can simply do the following:\n\n```javascript\nconst dayjs = require('dayjs')\nimport advancedFormat from 'dayjs\u002Fplugin\u002FadvancedFormat'\ndayjs.extend(advancedFormat)\n\nVue.filter('toDate', function(timestamp) {\n  return dayjs(timestamp*1000).format('Do MMM YY')\n})\n```\n\nThe reason we needed to import the advancedFormat plugin for day.js is simply because 'Do' is not included in the default day.js installation.\n\nWe can now format dates in the same way as with moment but using a much smaller library.\n\nI'll try to add more examples here in the future.","\u002Fimages\u002Fposts\u002F5b3b8b4fd4f28build-analyser-after.png",1530628764,1563372862,{"title":91,"title_slug":92,"tags":93,"meta_description":94,"content":95,"image":96,"_created":97,"_modified":98},"Setting up Automatic Deployment and Builds Using Webhooks","setting-up-automatic-deployment-and-builds-using-webhooks",[75,56],"This post demonstrates a reasonably simple way to automatically deploy and run code builds for your sites using webhooks and GitHub (you can use GitLab or whatever your preference is). We will create a webhook that is fired when we push updates to our origin repository and then use an incoming webhook server to listen for these and to fetch our updates then run our build scripts.","## Installing our Incoming Webhook Server\n\nWe'll be using the following incoming webhook server to acheive our goal - https:\u002F\u002Fgithub.com\u002Fadnanh\u002Fwebhook\n\nThis webhook server is written in Go and is really simply to get set up. It is easy to configure as the config file is just JSON.\n\nI'll be spinning up a fresh droplet with DigitalOcean for this example but you can use your own existing server and websites.\n\nWe could use this process to test in a staging environment but for this post we'll just be keeping it simple.\n\nI'll be running all commands in this post as a user `johndoe` with sudo permissions.\n\nIf you intend to run any npm commands in your build script make sure you have nodejs installed on your server.\n\nThe first thing we need to do is install golang on our server so that we can then install the incoming webhook server. You can do so using the following commands, make sure to find the latest stable version from the list here - [https:\u002F\u002Fgolang.org\u002Fdl\u002F](https:\u002F\u002Fgolang.org\u002Fdl\u002F)\n\ne.g. `go1.10.3.linux-amd64.tar.gz`\n\n```bash\ncd ~\nwget https:\u002F\u002Fdl.google.com\u002Fgo\u002Fgo\u003CVERSION>.\u003COS>-\u003CARCH>.tar.gz \nsudo tar -C \u002Fusr\u002Flocal -xzf go\u003CVERSION>.\u003COS>-\u003CARCH>.tar.gz \nexport PATH=$PATH:\u002Fusr\u002Flocal\u002Fgo\u002Fbin\n```\n\nThen we can simply install the latest version of webhook with the following command:\n\n```bash\ngo get github.com\u002Fadnanh\u002Fwebhook\n```\n\nThis will create a file `~\u002Fgo\u002Fbin\u002Fwebhook`, in my case `\u002Fhome\u002Fjohndoe\u002Fgo\u002Fbin\u002Fwebhook`.\n\n## Configuring our Webhooks\n\nCreate a folder called `~\u002Fhooks` and then create a folder inside hooks with the same name as the website your going to deploy. In my case I'll just call it `my-site-1`. This is where we'll put our `deploy.sh` script and also an `output.log` file.\n\n```bash\t\nmkdir ~\u002Fhooks\nmkdir ~\u002Fhooks\u002Fmy-site-1\n```\n\nNow create a new file inside the hooks directory and add following inside JSON inside, make sure to change my-site-1 to the name of your site and also change the command-working-directory to the correct root directory of your site: \n\n```bash\t\nnano ~\u002Fhooks\u002Fhooks.json\n```\n\n```json\n[\n  {\n    \"id\": \"deploy-my-site-1\",\n    \"execute-command\": \"\u002Fhome\u002Fjohndoe\u002Fhooks\u002Fmy-site-1\u002Fdeploy.sh\",\n    \"command-working-directory\": \"\u002Fvar\u002Fwww\u002Fmy-site-1\u002F\",\n    \"response-message\": \"Executing deploy script...\",\n     \"trigger-rule\":\n    {\n      \"and\":\n      [\n        {\n          \"match\":\n          {\n            \"type\": \"payload-hash-sha1\",\n            \"secret\": \"\u003CRANDOM-SECRET-STRING>\",\n            \"parameter\":\n            {\n              \"source\": \"header\",\n              \"name\": \"X-Hub-Signature\"\n            }\n          }\n        },\n        {\n          \"match\":\n          {\n            \"type\": \"value\",\n            \"value\": \"refs\u002Fheads\u002Fmaster\",\n            \"parameter\":\n            {\n              \"source\": \"payload\",\n              \"name\": \"ref\"\n            }\n          }\n        }\n      ]\n    }\n  }\n]\n```\n\nReplace \u003CRANDOM-SECRET-STRING> with a long random string e.g. H4GMvnc3v^fiK#r3qJMTTsk%ZL4Hdq we will need to use this later in GitHub when setting up the webhook.\n\nInside the `~\u002Fhooks\u002Fmy-site-1` folder create an `output.log` file. Then create a file named `deploy.sh`.\n\n```bash\ncd ~\u002Fhooks\u002Fmy-site-1\ntouch output.log\ntouch deploy.sh\nchmod +x deploy.sh\n```\nThe chmod command simply makes the .sh file executable.\n\nAdd the following inside deploy.sh (update to suit your sites needs):\n\n```bash\n#!\u002Fusr\u002Fbin\u002Fenv bash\n# redirect stdout\u002Fstderr to a file\nexec > \u002Fhome\u002Fjohndoe\u002Fhooks\u002Fmy-site-1\u002Foutput.log 2>&1\n\ngit fetch --all\n\ngit checkout --force \"origin\u002Fmaster\"\n\nnpm install --production\n\nnpm run production\n\ncomposer install --no-dev\n\nphp artisan route:cache\n\nphp artisan config:cache\n    \nphp artisan view:cache\n\nphp artisan queue:restart\n```\n\nThe third line of the above simply redirects all output to our `output.log` file. Then we run git fetch and get checkout to get our code updates from our origin repo (in my case GitHub).\n\nYou can update the other commands to suit your needs. Since I'm using a `Laravel` app as an example I'll run some artisan commands to clear the cache and restart the queue etc.\n\nThe incoming webhook server runs on `port 9000` by default you can change this if you wish as described [here](https:\u002F\u002Fgithub.com\u002Fadnanh\u002Fwebhook\u002Fblob\u002Fmaster\u002Fdocs\u002FWebhook-Parameters.md) but for our example we'll just leave it.\n\nYou now need to make sure that port 9000 is open on your server, which may involve updating your firewall rules. If you're using a service such as [RunCloud](https:\u002F\u002Fruncloud.io\u002Fr\u002FBVrWyymBWKNk) (affiliate link) this is very easily done from the user interface.\n\nOnce you've made sure port 9000 is open we can try running the server to see if everything is working so far.\n\n## Starting up our Webhook Server\n\nTo start the server enter the following command making sure to change johndoe to your user's username and \u003CYOUR-SERVER-IP> with the IP of the your server.\n\n```bash\n\u002Fhome\u002Fjohndoe\u002Fgo\u002Fbin\u002Fwebhook -hooks \u002Fhome\u002Fjohndoe\u002Fhooks\u002Fhooks.json -ip \"\u003CYOUR-SERVER-IP>\" -verbose\n```\n\nIf you now visit `http:\u002F\u002F\u003CYOUR-SERVER-IP>:9000\u002Fhooks\u002Fdeploy-my-site-1` in the browser you should see a message saying `Hook rules were not satisfied.` This is because the rules we specified in hooks.json including the secret string were not included in our request and therefore not satisfied.\n\nStop the webhook server by typing CTRL+C in the terminal.\n\nGo to your sites web root on your server and initialise a git repository, then add your remote GitHub url.\n\n```bash\ncd \u002Fvar\u002Fwww\u002Fmy-site-1\ngit init\ngit remote add origin git@my-site-1:willbrowningme\u002Fmy-site-1.git\n```\n\nThe reason we use the above as the remote origin url is so that we can use an alias in our `~\u002F.ssh\u002Fconfig` file to specify which ssh key to use when connecting. Update my-site-1 to the name of your repo. If you are using GitLab or another service that allows multiples repos per key then you can have the above as ` git@github.com:willbrowningme\u002Fmy-site-1.git `.\n\n\u003Cdiv class=\"blog-note\">\n    \u003Cb>Note: \u003C\u002Fb> The alias is needed when using GitHub if you want to have multiple deploy scripts on your server since GitHub only allows one unique Deploy Key per repository.\n\u003C\u002Fdiv>\n\nIn `~\u002F.ssh\u002F` create a new file called config and add the following inside:\n\nIf the .ssh directory doesn't exist yet then simply create it by running `mkdir ~\u002F.ssh`.\n\n```bash\nnano ~\u002F.ssh\u002Fconfig\n```\n\n```bash\n# My Site 1 Repo\nHost my-site-1 github.com\nHostName github.com\nIdentityFile ~\u002F.ssh\u002Fmy_site_1_id_rsa\n```\n\nMake sure to change my-site-1 to the alias you definied above for the remote branch and also the IdentityFile to the path for the private key we are about to generate.\n\nIf we try to now run `git fetch -all` we will get an error saying Permission denied (publickey). This is because we haven't yet set up a deploy key for the repo in GitHub.\n\n## Generating our Deploy Key\n\nTo fix this let's generate a new ssh key paid on our server by running. Substitute the email for your GitHub email.\n\nWhen it asks `Enter a file in which to save the key` name it like so - `\u002Fhome\u002Fjohndoe\u002F.ssh\u002Fmy_site_1_id_rsa` and leave the passphrase blank. (replace my_site_1 with the name of your github repo)\n\n```bash\nssh-keygen -t rsa -b 4096 -C \"your@github-email.com\"\n```\n\nAgain, the reason we are doing this is because GitHub only allows one deploy key to be used for each repository. You cannot use the same key for multiple repositories. Hence the naming convention.\n\nI beleive GitLab does allow you to use it for multiple repos so you can just leave the name as default `id_rsa` in that case if you wish.\n\nNow we need to copy the public key and add it to GitHub as a `deploy key`. So open up the pub key file.\n\n```bash\nvi ~\u002F.ssh\u002Fmy_site_1_id_rsa.pub\n```\n\nCopy the contents of this file and then type `:q` to quit the vim editor.\n\nOn GitHub go to the repo in question click on settings and then `Deploy Keys`. Click \"add deploy key\" and paste in the contents of your public key we just generated.\n\n\u003Cdiv class=\"blog-image\">\n\n![GitHub Deploy Key](\u002Fimages\u002Fposts\u002F5b3a2715d43f5github-deploy-key.png)\n\u003C\u002Fdiv>\n\nNow back in your websites web root and try to run the following again.\n\n```bash\ncd \u002Fvar\u002Fwww\u002Fmy-site-1\ngit fetch --all\ngit checkout --force \"origin\u002Fmaster\"\n```\n\nWith any luck the commands should work correctly now.\n\nIn the GitHub repo go to settings then webhooks and click \"add a webhook\". For the payload url enter `http:\u002F\u002F\u003CYOUR-SERVER-IP>:9000\u002Fhooks\u002Fdeploy-my-site-1` replacing your server IP and the ID you gave in hooks.json for the webhook.\n\n\u003Cdiv class=\"blog-image\">\n\n![GitHub Webhook](\u002Fimages\u002Fposts\u002F5b3a25f91d383github-webhook.png)\n\u003C\u002Fdiv>\n\nChoose application\u002Fjson for the content type and make sure to enter the random secret string you generated ealier in our hooks.json file. These will need to match or the script will not be exectuted. Choose \"just the push event\" and save the webhook.\n\n## Testing it Works\n\nNow we need to test it all works as planned. So start up your webhook server again by running:\n\n```bash\n\u002Fhome\u002Fjohndoe\u002Fgo\u002Fbin\u002Fwebhook -hooks \u002Fhome\u002Fjohndoe\u002Fhooks\u002Fhooks.json -ip \"\u003CYOUR-SERVER-IP>\" -verbose\n```\n\nMake an edit or an update to your code on your local pc repository so that we can commit the changes and then push them to GitHub by running:\n\n```bash\ngit push origin master\n```\n\nThis should now trigger GitHub to send the webhook delivery to our server which will then run the `deploy.sh` script for my-site-1 and will fetch the updates we just made and then build the site with the commands we gave.\n\nIf you visit GitHub settings and then webhooks you should see the new delivery under `Recent Deliveries`. Make sure it has a `200` response code and shows the response body we gave of \"Executing deploy script...\".\n\nStop the webhook server by typing CTRL+C into the terminal.\n\n## Installing Supervisor to Keep our Webhook Server Running\n\nNow that everything is working as planned let's install `supervisor` so we can keep the webhook server running in the background.\n\nSo below we install supervisor then create a new .conf file inside the `\u002Fetc\u002Fsupervisor\u002Fconf.d ` directory.\n\n```bash\nsudo apt install supervisor\ncd \u002Fetc\u002Fsupervisor\u002Fconf.d\nsudo nano webhooks.conf\n```\n\nAdd the following inside the `webhooks.conf` file, replacing the username and IP etc. with your values.\n\n```bash\n[program:webhooks]\ncommand=bash -c \"\u002Fhome\u002Fjohndoe\u002Fgo\u002Fbin\u002Fwebhook -hooks \u002Fhome\u002Fjohndoe\u002Fhooks\u002Fhooks.json -ip '\u003CYOUR-SERVER-IP>' -verbose\"\nredirect_stderr=true\nautostart=true\nautorestart=true\nuser=johndoe\nnumprocs=1\nprocess_name=%(program_name)s_%(process_num)s\nstdout_logfile=\u002Fhome\u002Fjohndoe\u002Fhooks\u002Fsupervisor.log\nenvironment=HOME=\"\u002Fhome\u002Fjohndoe\",USER=\"johndoe\"\n```\n\nSave this file and then run.\n\n```bash\ntouch ~\u002Fhooks\u002Fsupervisor.log\nsudo supervisorctl reread\nsudo supervisorctl update\nsudo supervisorctl start webhooks:*\n```\n\nI had a lot of issues with the webhooks.conf file and getting supervisor to start the server as the non root user. Initially it kept running the server as root which would then cause all the npm and git commands inside `deploy.sh` to fail. \n\nHowever I managed to get it working correctly by setting the right environment variables and then running the command through `bash -c \"the-command-here\"`.\n\nSo now we should have the webhooks server running nicely in the background ready to receive incoming deliveries from GitHub.\n\nMake another edit to your local code and push it to GitHub to make sure everything is still working as it should.\n\nCheck the `output.log` file at `~\u002Fhooks\u002Fmy-site-1\u002Foutput.log` to see the output from `deploy.sh`.\n\n\n## Adding Another Site\n\nIf you want to add another site with a different set of deploy and build commands you can follow these steps:\n\nFirst let's edit hooks.json so that it looks something like this:\n\n```json\n[\n  {\n    \"id\": \"deploy-my-site-1\",\n    \"execute-command\": \"\u002Fhome\u002Fjohndoe\u002Fhooks\u002Fmy-site-1\u002Fdeploy.sh\",\n    \"command-working-directory\": \"\u002Fvar\u002Fwww\u002Fmy-site-1\u002F\",\n    \"response-message\": \"Executing deploy script...\",\n     \"trigger-rule\":\n    {\n      \"and\":\n      [\n        {\n          \"match\":\n          {\n            \"type\": \"payload-hash-sha1\",\n            \"secret\": \"\u003CRANDOM-SECRET-STRING>\",\n            \"parameter\":\n            {\n              \"source\": \"header\",\n              \"name\": \"X-Hub-Signature\"\n            }\n          }\n        },\n        {\n          \"match\":\n          {\n            \"type\": \"value\",\n            \"value\": \"refs\u002Fheads\u002Fmaster\",\n            \"parameter\":\n            {\n              \"source\": \"payload\",\n              \"name\": \"ref\"\n            }\n          }\n        }\n      ]\n    }\n  },\n  {\n    \"id\": \"deploy-my-site-2\",\n    \"execute-command\": \"\u002Fhome\u002Fjohndoe\u002Fhooks\u002Fmy-site-2\u002Fdeploy.sh\",\n    \"command-working-directory\": \"\u002Fvar\u002Fwww\u002Fmy-site-2\u002F\",\n    \"response-message\": \"Executing deploy script...\",\n     \"trigger-rule\":\n    {\n      \"and\":\n      [\n        {\n          \"match\":\n          {\n            \"type\": \"payload-hash-sha1\",\n            \"secret\": \"\u003CRANDOM-SECRET-STRING>\",\n            \"parameter\":\n            {\n              \"source\": \"header\",\n              \"name\": \"X-Hub-Signature\"\n            }\n          }\n        },\n        {\n          \"match\":\n          {\n            \"type\": \"value\",\n            \"value\": \"refs\u002Fheads\u002Fmaster\",\n            \"parameter\":\n            {\n              \"source\": \"payload\",\n              \"name\": \"ref\"\n            }\n          }\n        }\n      ]\n    }\n  }\n]\n```\n\nWe then need to add a new folder called my-site-2 and then a new file called `deploy.sh`, making sure to change the output file too.\n\n```bash\nmkdir ~\u002Fhooks\u002Fmy-site-2\ntouch ~\u002Fhooks\u002Fmy-site-2\u002Foutput.log\nnano ~\u002Fhooks\u002Fmy-site-2\u002Fdeploy.sh\n```\nInside our new deploy.sh file add the following:\n\n```bash\n#!\u002Fusr\u002Fbin\u002Fenv bash\n# redirect stdout\u002Fstderr to a file\nexec > \u002Fhome\u002Fjohndoe\u002Fhooks\u002Fmy-site-2\u002Foutput.log 2>&1\n\ngit fetch --all\n\ngit checkout --force \"origin\u002Fmaster\"\n\nnpm install --production\n\nnpm run production\n\ncomposer install --no-dev\n\nphp artisan route:cache\n\nphp artisan config:cache\n    \nphp artisan view:cache\n\nphp artisan queue:restart\n```\nRemember to make it executable too.\n\n```bash\nchmod +x ~\u002Fhooks\u002Fmy-site-2\u002Fdeploy.sh\n```\n\nThen we need to generate a new key pair named my_site_2_id_rsa and add the public key to the `deploy key` section in the github repo just like we did earlier.\n\nSo initialise a new git repo (if you haven't got one already) and add the corresponding remote origin url to your code in `\u002Fvar\u002Fwww\u002Fmy-site-2` (or wherever you site is located).\n\n```bash\ncd \u002Fvar\u002Fwww\u002Fmy-site-2\ngit init\ngit remote add origin git@my-site-2:willbrowningme\u002Fmy-site-2.git\n```\n\nThen update ~\u002F.ssh\u002Fconfig\n\n```bash\n# My Site 1 Repo\nHost my-site-1 github.com\nHostName github.com\nIdentityFile ~\u002F.ssh\u002Fmy_site_1_id_rsa\n\n# My Site 2 Repo\nHost my-site-2 github.com\nHostName github.com\nIdentityFile ~\u002F.ssh\u002Fmy_site_2_id_rsa\n```\n\nTest using git fetch\n\n```bash\ngit fetch --all\ngit checkout --force \"origin\u002Fmaster\"\n```\n\nIf all went well the git fetch command should have worked.\n\nNext make sure to restart the supervisor job as we have updated the hooks file.\n\n```bash\nsudo supervisorctl reload\n```\n\nNow make an edit on your local repo of the your second project and test pushing the changes to origin.\n\n`Success!` You should now be set up with automatic deployments and builds for the sites.\n\nIf you can see a way to improve on this setup then please let me know in the comments.","\u002Fimages\u002Fposts\u002F5b3a25f91d383github-webhook.png",1530022193,1568017371,{"title":100,"title_slug":101,"tags":102,"meta_description":103,"content":104,"image":105,"_created":106,"_modified":107},"Building a Static Blog with Nuxt.js and Cockpit Headless CMS - Part 3:  Deployment","building-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-3-deployment",[18,19],"In this post we'll look at adding a few finishing touches to the site and then at how we can go about deploying it. Including setting up automatic regeneration for the site when a post is updated, added or removed.","If you haven't read Parts 1 and 2 of this guide you can find them here - [Part 1: Setup](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-1-setup\u002F) and here - [Part 2: Dynamic Routes](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-2-dynamic-routes)\n\nBefore we look at deploying our static blog let's add a few finishing touches like a sitemap and page titles etc.\n\n## Adding a Sitemap\n\nIn the project root run the following to install the Nuxt Community sitemap module:\n\n```bash\nnpm install @nuxtjs\u002Fsitemap --save-dev\n```\n\nThen in your nuxt.config.js add the sitemap to the modules: property\n\n```javascript\nmodules: [\n  \u002F\u002F Doc: https:\u002F\u002Fgithub.com\u002Fnuxt-community\u002Faxios-module#usage\n  '@nuxtjs\u002Faxios',\n  '@nuxtjs\u002Fsitemap'\n],\n```\n\nThen still in nuxt.config.js add the following code below the generate: property\n\n```javascript\nsitemap: {\n  path: '\u002Fsitemap.xml',\n  hostname: process.env.URL,\n  cacheTime: 1000 * 60 * 15,\n  generate: true, \u002F\u002F Enable me when using nuxt generate\n  async routes () {\n    let { data } = await axios.post(process.env.POSTS_URL,\n    JSON.stringify({\n        filter: { published: true },\n        sort: {_created:-1},\n        populate: 1\n      }),\n    {\n      headers: { 'Content-Type': 'application\u002Fjson' }\n    })\n\n    const collection = collect(data.entries)\n\n    let tags = collection.map(post => post.tags)\n    .flatten()\n    .unique()\n    .map(tag => `category\u002F${tag}`)\n    .all()\n\n    let posts = collection.map(post => post.title_slug).all()\n\n    return posts.concat(tags)\n  }\n},\n```\n\nHere we are simply letting the sitemap module know what routes we have.\n\n## Setting Page Titles and Meta\n\nWhen we deploy our site we want to have the correct page titles and meta descriptions for each post, so let's look at sorting this out.\n\nIn the head: {...} property of nuxt.config.js you'll see we have a title and meta property we can set. Set these to the default for your blog.\n\nLets look at our about.vue page we created in the first part of this guide. If you don't have one just create a new `about.vue` file in the pages directory and add the following:\n\n```html\n\u003Ctemplate>\n  \u003Csection class=\"my-8\">\n    \u003Cdiv class=\"text-center\">\n      \u003Ch1 class=\"mb-6\">About Page\u003C\u002Fh1>\n      \u003Cp>\n        Hi this is a static blog made with Nuxt.js, Cockpit and Tailwindcss!\n      \u003C\u002Fp>\n    \u003C\u002Fdiv>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n```javascript\n\u003Cscript>\nexport default {\n  head () {\n    return {\n      title: 'About',\n      meta: [\n        { hid: 'description', name: 'description', content: 'This is the about page!' }\n      ]\n    }\n  }\n}\n\u003C\u002Fscript>\n```\n\nNote the `hid` property, if we are declaring the same meta tags as in our nuxt.config.js we need include this so that Nuxt does not duplicate the meta tags. Instead it overides those in nuxt.config.js with the ones we add here with the same `hid` value.\n\nBut what about in our dynamic post and category pages? \n\nOpen up your `_title_slug.vue` page and add the following beneath the asyncData method:\n\n```javascript\nhead () {\n  return {\n    title: this.post.title,\n    meta: [\n      { hid: 'description', name: 'description', content: this.post.excerpt },\n    ]\n  }\n}\n```\n\nYou can run the dev server and make sure everything is working correctly and the page titles are being set.\n\nDo the same for `_tag.vue` in the category directory.\n\n```javascript\nhead () {\n  return {\n    title: `Posts tagged with ${this.category}`,\n    meta: [\n      { hid: 'description', name: 'description', content: `All blog posts categorised as ${this.category}.` },\n    ]\n  }\n}\n```\n\nIf you want to improve this further you can add meta tags for social media sites like Twitter, Google and Facebook.\n\nAlso using [Real Favicon Generator](https:\u002F\u002Frealfavicongenerator.net\u002F) you can create all the correct icons etc. Just add the files to your static directory and they will be copied over to the dist directory when you run `npm run generate`.\n\n## Displaying our Post Dates\n\nSo far we haven't displayed the creation date for any of our blog posts so let's look at how we can do this.\n\nInstall [day.js](https:\u002F\u002Fgithub.com\u002Fiamkun\u002Fdayjs) with the following command:\n\n```bash\nnpm install dayjs --save-dev\n```\n\nWe're using dayjs as we only want to do some simple date formatting and moment.js is overkill for this situation.\n\nOnce installed open up the filters.js file in the plugins directory and update it so that it looks like this:\n\n```javascript\nimport Vue from 'vue'\nimport highlightjs from 'highlight.js'\nimport marked, { Renderer } from 'marked'\nconst dayjs = require('dayjs')\nimport advancedFormat from 'dayjs\u002Fplugin\u002FadvancedFormat'\ndayjs.extend(advancedFormat)\n\n\u002F\u002F Only import the languages that you need to keep our js bundle small\nhighlightjs.registerLanguage('php', require('highlight.js\u002Flib\u002Flanguages\u002Fphp'))\nhighlightjs.registerLanguage('javascript', require('highlight.js\u002Flib\u002Flanguages\u002Fjavascript'))\nhighlightjs.registerLanguage('css', require('highlight.js\u002Flib\u002Flanguages\u002Fcss'))\n\n\u002F\u002F Create your custom renderer.\nconst renderer = new Renderer()\nrenderer.code = (code, language) => {\n  \u002F\u002F Check whether the given language is valid for highlight.js.\n  const validLang = !!(language && highlightjs.getLanguage(language))\n  \u002F\u002F Highlight only if the language is valid.\n  const highlighted = validLang ? highlightjs.highlight(language, code).value : code\n  \u002F\u002F Render the highlighted code with `hljs` class.\n  return `\u003Cpre>\u003Ccode class=\"hljs ${language}\">${highlighted}\u003C\u002Fcode>\u003C\u002Fpre>`\n}\n\n\u002F\u002F Set the renderer to marked.\nmarked.setOptions({ renderer })\n\nVue.filter('parseMd', function(content) {\n    return marked(content)\n})\n\nVue.filter('toDate', function(timestamp) {\n  return dayjs(timestamp*1000).format('Do MMM YY')\n})\n```\n\nWe needed to import `advancedFormat` since the `Do` date format is not included in dayjs by default. If you want to format you dates differently you might not need this.\n\nCockpit returns our created date as a timestamp in seconds, so we need to multiply it by 1000 to get it into milliseconds. Then we just format it to our liking.\n\nYou can now go and update `index.vue`, `_tag.vue` and `_title_slug.vue` to include the post's created date like so `{{ post._created | toDate }}`.\n\nYour site should now look something like this.\n\n\u003Cdiv class=\"blog-image\">\n    \n![Posts with dates](\u002Fimages\u002Fposts\u002F5b2b9e362f627posts-with-dates.png) \n\u003C\u002Fdiv>\n\n## Deploying the Site\n\nNow that our site is in reasonable shape let's look at deploying it.\n\nBy far the easiest place for us to deploy our site is [Netlify](https:\u002F\u002Fwww.netlify.com\u002F).\n\nWe can simply link our git repository on GitHub\u002FGitLab\u002FBitbucket and it will automatically be updated and rebuilt on Netlify whenever we push changes. We can also easily add webhooks that allow us to tell Netlify to regenerate the site when we update one of our blog posts in Cockpit.\n\nJust before we do this we need to add a little script to the root of our site that will allow Netlify to create a .env file at the time it builds our site.\n\nThe reason we need to do this is because we added our .env file to our .gitignore file so it won't be committed to git and Netlify won't have access to our Cockpit API key!\n\nSo create a new file called `create-env.js` and add the following to it:\n\n```javascript\nconst fs = require('fs')\nfs.writeFileSync('.\u002F.env', `\nBASE_URL=${process.env.BASE_URL}\\n\nPOSTS_URL=${process.env.POSTS_URL}\\n\nURL=${process.env.URL}\n`)\n```\n\nAll this little script does is create a .env file from the `Build environment variables` that we will set up in Netlify soon.\n\nIf you haven't already initialise a git repository for your site and then push it to whichever service you use (e.g. GitHub).\n\nSign up at [Netlify](https:\u002F\u002Fwww.netlify.com\u002F) (it's free) and add a new site from git.\n\nWhen you've allowed Netlify access and selected the correct git repository you need to add the following under `Deploy Settings` as the Build command: \n\n```bash\nnode .\u002Fcreate-env.js && npm run generate\n```\n\nRemember to set the `Publish directory` as dist.\n\nThis tells Netlify to run our `create-env.js` script above and write to a .env file so we can use our Cockpit API key etc.\n\nFinally we need to tell Netlify what our `Build environment variables` are so click \"new variable\" until you have something like this.\n\n\u003Cdiv class=\"blog-image\">\n    \n![Build Environment Variables](\u002Fimages\u002Fposts\u002F5c8133450e106netlify-environment-variables.png) \n\u003C\u002Fdiv>\n\nNow with any luck you'll be able to push changes to GitHub etc and Netlify will automatically be notified of the changes and rebuild your site by running the `npm run generate` command we specified above!\n\n\u003Cdiv class=\"blog-note\">\n    \u003Cb>Note:\u003C\u002Fb> If you run into errors or issues during the build phase with Netlify try changing the Build image\n\u003C\u002Fdiv>\n\n## Setting up Build Webhooks\n\nSo we've got automatic deploys set up for pushing changes to GitHub etc. but now we need to tell Netlify to rebuild of static site when we update, add or delete a post in Cockpit.\n\nIn Netlify under \"Build & Deploy\" Settings you should see an option to add a build hook.\n\nClick on this and call it something like `Regenerate Blog`.\n\nYou should then see a URL like this `https:\u002F\u002Fapi.netlify.com\u002Fbuild_hooks\u002Fxxxxxxxxxxxxxxxx` copy this URL and then head over to your Cockpit backend - `https:\u002F\u002Fcms.yourdomain.com`.\n\nOnce signed into Cockpit go to settings, webhooks and click \"create a webhook\". Call the webhook Regenerate Blog or anything like that and paste in your Netlify Build Hook URL.\n\nMake sure to add events `collections.save.after` and `collections.remove.after`.\n\n\u003Cdiv class=\"blog-image\">\n    \n![Build Environment Variables](\u002Fimages\u002Fposts\u002F5b2ba870d45a5cockpit-webhook.png) \n\u003C\u002Fdiv>\n\nClick save and then go edit one of your posts to see if everything is working.\n\nYou should see after a minute or so that Netlify has automatically regenerated the static site for us!\n\nYou can now go on to add your own custom domain to your blog and also add an SSL certifcate with forced https redirection.\n\n## Deploying without using Netlify\n\nWe could also create a similar setup to the above on our own Digitalocean, Vultr etc. VPS using a small server to accept webhooks and run shell commands. `I'll cover this in a future post!`\n\n`Update!` You can find my post explaining this here - [Setting up Automatic Deployment and Builds Using Webhooks](https:\u002F\u002Fwillbrowning.me\u002Fsetting-up-automatic-deployment-and-builds-using-webhooks)\n\n## Wrapping up\n\nHopefully you can see how easy it is to get up and running with a simple statically generate site using Nuxt and Cockpit. Paired with Netlify it really is a great developer experience and being served on Netlify's CDN makes it extremely fast!\n\nYou can check out the GitHub repo of the finished blog [here](https:\u002F\u002Fgithub.com\u002Fwillbrowningme\u002Fnuxt-cockpit-static-blog) and see a live demo of the site on Netlify here - [https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com](https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com\u002F)","\u002Fimages\u002Fposts\u002F5b2b9e362f627posts-with-dates.png",1529582658,1569936698,{"title":109,"title_slug":110,"tags":111,"meta_description":112,"content":113,"image":114,"_created":115,"_modified":116},"Building a Static Blog with Nuxt.js and Cockpit Headless CMS - Part 2:  Dynamic Routes","building-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-2-dynamic-routes",[18,19],"In this post we'll be setting up our category page to display all posts that have a tag matching our category. We'll also be setting up our dynamic routes so that when we run \"npm run generate\" all the individual post and category routes will be correctly generated.","If you haven't read Part 1 of this guide you can find it here - [Part 1: Setup](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-1-setup\u002F)\n\n## Generating our dynamic routes for individual blog posts\n\nYou might be wondering how can we generate static pages for each blog post when deploying or updating our site?\n\nNuxt comes with an easy solution for this, so open up your nuxt.config.js and add the following above the build: {...} property:\n\n```javascript\ngenerate: {\n  routes: async () => {\n    let { data } = await axios.post(process.env.POSTS_URL,\n    JSON.stringify({\n        filter: { published: true },\n        sort: {_created:-1},\n        populate: 1\n      }),\n    {\n      headers: { 'Content-Type': 'application\u002Fjson' }\n    })\n    return data.entries.map((post) => {\n      return {\n        route: post.title_slug,\n        payload: post\n      }\n    })\n  }\n},\n```\n\nSo what's going on here? Well first we make a call to our Cockpit backend to get our post entries. We then map this response into an object containing the actual `route` (we're using the title slug for this) and also a `payload` object.\n\nThe payload we set to the entire post entry. This will be passed to each generated blog post and we'll be able to access it and display the contents.\n\nThis makes generating our static site faster as we won't need to fetch each blog post individually from every blog post page we generate.\n\nYou can read more about this at [Nuxtjs.org.](https:\u002F\u002Fnuxtjs.org\u002Fapi\u002Fconfiguration-generate#speeding-up-dynamic-route-generation-with-code-payload-code-)\n\nSo now we've told our blog what routes it needs to have we need to create a page that will display the contents of individual blog posts.\n\nThe convention for dynamic pages in Nuxt is to name the page like so `_title_slug.vue` where title_slug is the unique route identifier in our case. Notice also we have prefixed title_slug with an underscore.\n\nSo create a new file called `_title_slug.vue` in the pages directory. If you want your links to be \u002Fblog\u002Ftitle_slug instead of just \u002Ftitle_slug then you need to create a blog directory in the pages directory then put `_title_slug.vue` in there. You can of course use \u002Fpost\u002Ftitle_slug or whatever you like.\n\nInside the newly created `_title_slug.vue` file add this code:\n\n```html\n\u003Ctemplate>\n  \u003Csection>\n    \u003Carticle class=\"my-8\">\n      \u003Cdiv class=\"text-gray-600 font-bold text-sm tracking-wide\">\n        \u003Ca v-for=\"(tag, key) in post.tags\" :key=\"key\" :href=\"'\u002Fcategory\u002F'+tag\" class=\"ml-1\">{{ tag }}\u003C\u002Fa>\n      \u003C\u002Fdiv>\n      \u003Ch1 class=\"mt-2 text-3xl font-bold\">\n        {{ post.title }}\n      \u003C\u002Fh1>\n      \u003Cdiv class=\"mt-4 markdown\" v-html=\"post.excerpt + '\\n' + post.content\">\n      \u003C\u002Fdiv>\n    \u003C\u002Farticle>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n```javascript\n\u003Cscript>\nexport default {\n  async asyncData ({ app, params, error, payload }) {\n    if (payload) {\n      return { post: payload }\n    } else {\n      let { data } = await app.$axios.post(process.env.POSTS_URL,\n      JSON.stringify({\n          filter: { published: true, title_slug: params.title_slug },\n          sort: {_created:-1},\n          populate: 1\n        }),\n      {\n        headers: { 'Content-Type': 'application\u002Fjson' }\n      })\n\n      if (!data.entries[0]) {\n        return error({ message: '404 Page not found', statusCode: 404 })\n      }\n\n      return { post: data.entries[0] }\n    }\n  }\n}\n\u003C\u002Fscript>\n```\n\nSo as you can see we accept the payload as an argument in the asyncData method. We then check if we have the payload available (which is the post for that particular page in our case). If we do then we simply return it as `post` to the page data (you can check in Vue dev-tools).\n\nIf we don't have a payload i.e. when running our dev server then we simply send a post request to Cockpit. Notice the filter object in the request body that asks for the post with the same title_slug as the requested page. We can then check if this post exists in the response, if it does we return it and if not return the 404 error page.\n\nFire up the dev server again with `npm run dev`. You should have something that looks like this.\n\n\u003Cdiv class=\"blog-image\">\n    \n![Individual Post](\u002Fimages\u002Fposts\u002F5b2a9462bd065individual-post.png) \n\u003C\u002Fdiv>\n\nNow you may have noticed our markdown is not being parsed and it looks really messy. Don't worry we'll fix this soon!\n\n\n**Note:** If you're looking for some markdown placeholder text you can use [Lorum Markdown](https:\u002F\u002Fjaspervdj.be\u002Florem-markdownum\u002F) to generate some.\n  \n## Parsing our Markdown\n   \nLet's sort out our markdown parsing and code higlighting.\n\n```bash\nnpm install marked highlight.js --save-dev\n```\n\nWe'll make a global filter that we can use to parse our Markdown so create a file called `filters.js` in the plugins directory and put this in it:\n\n```javascript\nimport Vue from 'vue'\nimport highlightjs from 'highlight.js'\nimport marked, { Renderer } from 'marked'\n\n\u002F\u002F Only import the languages that you need to keep our js bundle small\nhighlightjs.registerLanguage('php', require('highlight.js\u002Flib\u002Flanguages\u002Fphp'))\nhighlightjs.registerLanguage('javascript', require('highlight.js\u002Flib\u002Flanguages\u002Fjavascript'))\nhighlightjs.registerLanguage('css', require('highlight.js\u002Flib\u002Flanguages\u002Fcss'))\n\n\u002F\u002F Create your custom renderer.\nconst renderer = new Renderer()\nrenderer.code = (code, language) => {\n  \u002F\u002F Check whether the given language is valid for highlight.js.\n  const validLang = !!(language && highlightjs.getLanguage(language))\n  \u002F\u002F Highlight only if the language is valid.\n  const highlighted = validLang ? highlightjs.highlight(language, code).value : code\n  \u002F\u002F Render the highlighted code with `hljs` class.\n  return `\u003Cpre>\u003Ccode class=\"hljs ${language}\">${highlighted}\u003C\u002Fcode>\u003C\u002Fpre>`\n}\n\n\u002F\u002F Set the renderer to marked.\nmarked.setOptions({ renderer })\n\nVue.filter('parseMd', function(content) {\n    return marked(content)\n})\n```\n\nMake sure you also add the following to nuxt.config.js underneath the head:{...} property\n\n```javascript\nplugins: [\n  '~\u002Fplugins\u002Ffilters.js'\n],\n```\n\nWe can now use this filter globally!\n\nBack in `_title_slug.vue` in the template where it says v-html we can now access our filter by putting:\n\n```javascript\nv-html=\"$options.filters.parseMd(post.excerpt + '\\n' + post.content)\"\n```\n\nI know this isn't the prettiest solution but unfortunately we can't just pipe filters using '|' like we would usually - `{{ some-markdown | parseMd }}` as it isn't possible in v-html.\n\nYou can create a method to call instead if you would like to tidy it up.\n\nBack in nuxt.config.js update the css: property to include a theme for highlight.js - [full list here.](https:\u002F\u002Fjmblog.github.io\u002Fcolor-themes-for-highlightjs\u002F)\n\n```javascript\n css: [\n  '@\u002Fassets\u002Fcss\u002Fmain.css',\n  'highlight.js\u002Fstyles\u002Fdracula.css'\n],\n```\n\n\u003Cdiv class=\"blog-image\">\n    \n![Individual Post](\u002Fimages\u002Fposts\u002F5b2a9b9a63367markdown-parsed.png) \n\u003C\u002Fdiv>\n\nThat's starting to look a bit more like it!\n\n## Generating our Category Routes\n\nOkay, so we've got our individual blog posts and their routes but we now want to generate routes for the different `post categories` based on their tags.\n\nFor example if we have a post tagged `vue` we want to be able to click on this tag to see all other posts that have been tagged `vue`.\n\nSo lets go back to nuxt.config.js and update our routes method in the generate: property.\n\nJust before we do let's add a package that lets us work with collections so we can easily get the data we need from our Cockpit response.\n\n```bash\nnpm install collect.js --save-dev\n```\n\nMake sure to add `const collect = require('collect.js')` at the top of our nuxt.config.js too.\n\nUpdate the generate property in nuxt.config.js so that is resembles the below.\n\n```javascript\ngenerate: {\n  routes: async () => {\n    let { data } = await axios.post(process.env.POSTS_URL,\n    JSON.stringify({\n        filter: { published: true },\n        sort: {_created:-1},\n        populate: 1\n      }),\n    {\n      headers: { 'Content-Type': 'application\u002Fjson' }\n    })\n\n    const collection = collect(data.entries)\n\n    let tags = collection.map(post => post.tags)\n    .flatten()\n    .unique()\n    .map(tag => {\n      let payload = collection.filter(item => {\n        return collect(item.tags).contains(tag)\n      }).all()\n\n      return {\n        route: `category\u002F${tag}`,\n        payload: payload\n      }\n    }).all()\n\n    let posts = collection.map(post => {\n      return {\n        route: post.title_slug,\n        payload: post\n      }\n    }).all()\n\n    return posts.concat(tags)\n  }\n},\n```\n\nSo here we use the same data returned from Cockpit as previously. Only this time we first collect the post entries into a const called `collection`.\n\nFor our tags we first map the collection into a new collection of just the post tags. Then we flatten this and call unique() on it to give us a collection of unique tags. (We would normally run flatMap() instead of calling map() and then flatten() however it wouldn't work as expected for me with collect.js)\n\nWith this unique collection of tags we map them into the route and payload properties like we did previously. For the tag payload we simply filter the original collection and return only post entries that have the specified tag. \n\nFor the posts we can simply map them directly into their route and payloads.\n\nFinally we just call `posts.concat(tags)` to join the two together and return this.\n\nSo now we've got routes for our posts and a category page for each unique post tag!\n\n## Creating our Category page\n\nSince we've set our category routes to be `\u002Fcategory\u002Ftag-name` we need to create a category directory inside the pages directory.\n\nInside the category directory create a new file called `_tag.vue` (following the same naming convention as before) and put the following inside:\n\n```html\n\u003Ctemplate>\n  \u003Csection>\n    \u003Cdiv class=my-8>\n      \u003Ch1 class=\"mb-6\">Posts tagged with \"{{ category }}\"\u003C\u002Fh1>\n      \u003Cul class=\"flex flex-col w-full p-0\">\n        \u003Cli class=\"mb-6 w-full\" v-for=\"(post, key) in posts\" :key=\"key\">\n          \u003Cdiv class=\"text-gray-600 font-bold text-sm tracking-wide\">\n            \u003Ca v-for=\"(tag, key) in post.tags\" :key=\"key\" :href=\"'\u002Fcategory\u002F'+tag\" class=\"ml-1\">{{ tag }}\u003C\u002Fa>\n          \u003C\u002Fdiv>\n\n          \u003Ca :href=\"'\u002F'+post.title_slug\">\n            \u003Ch2 class=\"my-2 text-gray-800 text-lg lg:text-xl font-bold\">\n              {{ post.title }}\n            \u003C\u002Fh2>\n          \u003C\u002Fa>\n\n          \u003Cdiv class=\"page-content hidden md:block text-base mb-2\" v-html=\"post.excerpt\">\n          \u003C\u002Fdiv>\n          \u003Ca class=\"text-sm text-blue-400\" :href=\"'\u002F'+post.title_slug\">\n            Read more\n          \u003C\u002Fa>\n        \u003C\u002Fli>\n      \u003C\u002Ful>\n    \u003C\u002Fdiv>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n```javascript\n\u003Cscript>\nexport default {\n  async asyncData ({ app, params, error, payload }) {\n    if (payload) {\n      return { posts: payload, category: params.tag }\n    } else {\n      let { data } = await app.$axios.post(process.env.POSTS_URL,\n      JSON.stringify({\n          filter: { published: true, tags: { $has:params.tag } },\n          sort: {_created:-1},\n          populate: 1\n        }),\n      {\n        headers: { 'Content-Type': 'application\u002Fjson' }\n      })\n\n      if (!data.entries[0]) {\n        return error({ message: '404 Page not found', statusCode: 404 })\n      }\n\n      return { posts: data.entries, category: params.tag }\n    }\n  }\n}\n\u003C\u002Fscript>\n```\n\nThis page is largely similar to our index.vue page in terms of the template. Notice that we again accept the payload from our nuxt.config.js if it's available. \n\nIf we don't have a payload then we make a post request to Cockpit and include in the filter `tags: { $has:params.tag }` this returns all posts that have a tag for that particular category. \n\nWe can't call params.tag directly in our template which is why we simply pass it to our data object as `category`.\n\nIn the next part we'll look at how to go about deploying our site and also adding some finishing touches.\n\nYou can find Part 3 here - [Part 3: Deployment](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-3-deployment) and see a live demo of the site on Netlify here - [https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com](https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com\u002F)","\u002Fimages\u002Fposts\u002F5b2a9b9a63367markdown-parsed.png",1529328219,1569938787,{"title":118,"title_slug":119,"tags":120,"meta_description":121,"content":122,"image":123,"_created":124,"_modified":125},"Building a Static Blog with Nuxt.js and Cockpit Headless CMS - Part 1: Setup","building-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-1-setup",[18,19],"If you've ever felt WordPress is too bloated and slow then you might want to try out building a statically generated blog, using a static site generator and a headless CMS.","## What we'll be building\n\n> Updated for Nuxt 2 and Tailwindcss 1.0!\n\nTldr; You can check out the GitHub repo of the finished blog [here](https:\u002F\u002Fgithub.com\u002Fwillbrowningme\u002Fnuxt-cockpit-static-blog) and see a live demo on Netlify - [https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com](https:\u002F\u002Fnuxt-cockpit-static-blog.netlify.com\u002F)\n\nWe'll be using the `generate` feature of [Nuxt.js](https:\u002F\u002Fnuxtjs.org) to generate a static blog and a headless CMS called [Cockpit](https:\u002F\u002Fgetcockpit.com) for the api.\n\nIt will be a JAMstack project, trying to follow the [best practices](https:\u002F\u002Fjamstack.org\u002Fbest-practices\u002F) layed out.\n\nThe definition of the JAMstack given on [jamstack.org](https:\u002F\u002Fjamstack.org\u002F) is: \n\n> Modern web development architecture based on client-side JavaScript, reusable APIs, and prebuilt Markup.\n\nIn our example we'll be writing `Markdown` in Cockpit for our posts that will be fetched by Nuxt.js and then parsed to HTML before generating our static blog.\n\n## Why a Static Site?\n\nHere are just a few benefits of generating a static site:\n\n* Better Performance\n* Higher Security\n* Cheaper, Easier Scaling\n* Better Developer Experience\n\nYou can use a number of different static site generators such as Jekyll, Hugo, Next or Gatsby. There are also many different options for your headless CMS e.g. self hosted options like Strapi, Directus, Ponzu or you can use hosted options like Contentful, Netlify, Prismic or Storyblok.\n\nFor a more comprehensive list of headless CMSs - [https:\u002F\u002Fheadlesscms.org\u002F](https:\u002F\u002Fheadlesscms.org\u002F)\n\nAnd for a list of static site generators - [https:\u002F\u002Fwww.staticgen.com\u002F](https:\u002F\u002Fwww.staticgen.com\u002F)\n\nFor the site we're building we'll be using Nuxt.js as I love working with Vue and also Cockpit as it's a PHP based Headless CMS and is very quick and easy to set up.\n\n## Website Structure\n\nWe'll be keeping the headless CMS backend separate from the frontend site. So you will need to create a new app directory on your server called something like `cms-yourblog` and another site called `yourblog`. \n\nYou can then use `yourdomain.com` for the frontend and a subdomain such as `cms.yourdomain.com` for the backend. You can obviously use whatever subdomain you like.\n\n## Setting up Cockpit\n\nI'm skipping setting up in our local environment with version control etc. here just to speed things up. But you may want to set Cockpit up locally first.\n\nWe don't actually need to do much configuration for Cockpit, you can simply download the zip file into your cms-yourblog web root directory and unzip the contents. \n\nThe third and forth commands below simply move the contents of the unzipped cockpit-master directory up one level to the current web root directory and then remove the empty cockpit-master directory.\n\n```bash\ncd \u002Fpath\u002Fto\u002Fyour\u002Fcms-yourblog\u002F\nwget \"https:\u002F\u002Fgithub.com\u002Fagentejo\u002Fcockpit\u002Farchive\u002Fmaster.zip\"\nunzip master.zip\nmv cockpit-master\u002F* cockpit-master\u002F.[^.]* .\nrmdir cockpit-master\nrm master.zip\n```\n\nYou can then go to `cms.yourdomain.com\u002Finstall` to finish off the installation process.\n\nOnce you've set up your new password and username we can create a posts `collection.` You can think of collections in cockpit like you would a table in a database.\n\nOur new posts collection will have the following fields:\n\n* **published** (type boolean) (options `{\"default\": false, \"label\": false}`)\n* **title** (type text) (options `{\"slug\": true}`)\n* **image** (type asset)\n* **excerpt** (type textarea) - we'll use this for our meta description\n* **content** (type markdown)\n* **tags** (type tags)\n\nMake sure to include the options in the provided JSON options field when adding the published and title fields.\n\n\u003Cdiv class=\"blog-image\">\n\n![Cockpit Collections](\u002Fimages\u002Fposts\u002F5b265ae2d783ccockpit-collections.png)\n\u003C\u002Fdiv>\n\nWe can now head over to settings then api access where we will generate an API key so we can retreive our posts data.\n\nYou should see there is a \"MASTER API-KEY\" that you can generate. This key will have full permisions for your site so you should avoid using this if possible.\n\nWhere it says `Custom Keys` click add key to add a new custom key. Then in the rules section add the following: `\u002Fapi\u002Fcollections\u002Fget\u002Fposts`\n\nThis means that our key will only have permission to access that particular end point for fetching blog posts. Add a small description too if you like.\n\n\u003Cdiv class=\"blog-image\">\n\n![Custom API Key](\u002Fimages\u002Fposts\u002F5be94679c3a32custom-api-key.png)\n\u003C\u002Fdiv>\n\nThis means if our API Key was ever accidently exposed then an attacker would only be able to view posts and not create\u002Fdelete them etc.\n\nCreate a couple of dummy post entries so we have some initial data to look at.\n\nIf you have [Postman](https:\u002F\u002Fwww.getpostman.com\u002F) or [Insomnia](https:\u002F\u002Finsomnia.rest\u002F) installed you can then send a get request to:\n\n`https:\u002F\u002Fcms.yourdomain.com\u002Fapi\u002Fcollections\u002Fget\u002Fposts?token=YOUR-API-TOKEN`\n\nThis should return your posts in the `entries` array of the response. \n\nNow that we've got our basic CMS setup that can return our post data we can move onto setting up Nuxt.js for the frontend.\n\n## Setting up Nuxt.js\n\nFirst of all we need to install Nuxt. We'll do this on our local computer and run the built in development server.\n\nTo install Nuxt run the following command:\n\n```bash\nnpx create-nuxt-app static-blog\n```\n\nWhere static-blog is the name of our app. It will ask a few questions, for `custom server framework` select none. For `custom UI framework` select none (we'll set tailwind up ourselves).\n\nFor the rendering mode select `Universal`. Select yes to use the `axios module`. We'll not bother with `eslint` or `prettier` for now so select no for both.\n\n\u003Cdiv class=\"blog-note\">\n    \u003Cb>Note:\u003C\u002Fb> Or if using Yarn. \"yarn create nuxt-app static-blog\"\n\u003C\u002Fdiv>   \n\nThis will create a folder called static-blog for our frontend, you can obviously call it whatever you like.\n\nNext we need to enter the newly created directory and run the development server (it should have already installed our dependencies).\n\n```bash\ncd static-blog\nnpm run dev\n```\n\nYou can now visit `http:\u002F\u002Flocalhost:3000` in your browser to see the site in action!\n\n\u003Cdiv class=\"blog-image\">\n\n![Nuxt Folders](\u002Fimages\u002Fposts\u002F5b2779e41e10fnuxt-folders.png)\n\u003C\u002Fdiv>\n\nOpen your preferred code editor (I'll be using `Visual Studio Code`) and take a look at the folder structure.\n\nNuxt automatically creates a route for each file in the pages directory.\n\nSo If we simply copy the index.vue file and rename it about.vue we will be able to visit it at `http:\u002F\u002Flocalhost:3000\u002Fabout`.\n\nWe'll use the [dotenv](https:\u002F\u002Fgithub.com\u002Fnuxt-community\u002Fdotenv-module) node module so we can access our .env variables inside nuxt.config.js. This module will allow us to create a .env file in our project root that we can store our secret api token and url in.\n\nYou should also add `.env` to your .gitignore file to make sure you don't accidently commit and push the contents to Github etc.\n\n```bash\nnpm install dotenv --save-dev\n```\n\nOnce installed open up `nuxt.config.js` and add the following at the very top of the file:\n\n```javascript\nrequire('dotenv').config()\n```\n\nIf you haven't already create a .env file at your project root and put the following inside:\n\n```env\nURL=https:\u002F\u002Fyourdomain.com\nBASE_URL=https:\u002F\u002Fcms.yourdomain.com\nPOSTS_URL=http:\u002F\u002Fcms.yourdomain.com\u002Fapi\u002Fcollections\u002Fget\u002Fposts?token=YOUR-API-TOKEN\n```\n\nMaking sure to replace `YOUR-API-TOKEN` with the token we generated earlier in Cockpit.\n\nWe'll now be able to access these variables throughout our blog using `process.env.POSTS_URL` for example.\n\nThe reason we used the dotenv package and didn't just add our api key to nuxt.config.js in the [env:{...} property](https:\u002F\u002Fnuxtjs.org\u002Fapi\u002Fconfiguration-env#the-env-property) is because this gets bundled up in a js file and exposed to the client. So someone would be able to simply open our `\u002F_nuxt\u002Fxxxxxxxxxxxxxxxxxxxx.js` file and see our api key in plain text!\n\nInstall tailwind for our css framework (feel free to use any other css framework you like).\n\n```bash\nnpm install tailwindcss --save-dev\n```\n\n## Setting up Tailwindcss with Purgecss\n\nNext initiate the tailwind config file by running:\n\n```bash\n.\u002Fnode_modules\u002F.bin\u002Ftailwind init tailwind.config.js\n```\n\nCreate a new directory called css inside the assets directory and then create a file in here called `main.css` and add the following to it:\n\n```css\n@tailwind base;\n\n@tailwind components;\n\n@tailwind utilities;\n```\n\nThen install the following dependencies:\n\n```bash\nnpm install autoprefixer glob-all purgecss-webpack-plugin --save-dev\n```\n\nThis will allow us to compile our css and also remove any unused css using purgecss.\n\nIn the root of the project create a file called `postcss.config.js` and insert the following:\n\n```javascript\nmodule.exports = {\n  plugins: [\n    require('tailwindcss')('.\u002Ftailwind.config.js'),\n    require('autoprefixer')\n  ]\n}\n```\n\nBack in nuxt.config.js add the following at the very top of the file above module.exports = {...\n\n```javascript\nrequire('dotenv').config() \u002F\u002F we already added this ealier when making our .env file\nconst PurgecssPlugin = require('purgecss-webpack-plugin')\nconst glob = require('glob-all')\nconst path = require('path')\nimport axios from 'axios' \u002F\u002F we'll need this later for our dynamic routes\n\nclass TailwindExtractor {\n  static extract(content) {\n    return content.match(\u002F[A-z0-9-:\\\u002F]+\u002Fg) || [];\n  }\n}\n```\n\nthen add our main.css file and update the build: {... object like this:\n\n```javascript\ncss: [\n  '@\u002Fassets\u002Fcss\u002Fmain.css'\n],\n\u002F*\n** Build configuration\n*\u002F    \nbuild: {\n  extractCSS: true,\n  \u002F*\n  ** You can extend webpack config here\n  *\u002F\n  extend (config, { isDev }) {\n    if (!isDev) {\n      \u002F\u002F Remove unused CSS using purgecss. See https:\u002F\u002Fgithub.com\u002FFullHuman\u002Fpurgecss\n      \u002F\u002F for more information about purgecss.\n      config.plugins.push(\n        new PurgecssPlugin({\n          \u002F\u002F Specify the locations of any files you want to scan for class names.\n          paths: glob.sync([\n            path.join(__dirname, '.\u002Fpages\u002F**\u002F*.vue'),\n            path.join(__dirname, '.\u002Flayouts\u002F**\u002F*.vue'),\n            path.join(__dirname, '.\u002Fcomponents\u002F**\u002F*.vue')\n          ]),\n          extractors: [\n            {\n              extractor: TailwindExtractor,\n              \u002F\u002F Specify the file extensions to include when scanning for\n              \u002F\u002F class names.\n              extensions: [\"html\", \"vue\"]\n            }\n          ],\n          whitelist: [\n            \"html\",\n            \"body\",\n            \"ul\",\n            \"ol\",\n            \"pre\",\n            \"code\",\n            \"blockquote\"\n          ],\n          whitelistPatterns: [\u002F\\bhljs\\S*\u002F]\n        })\n      )\n    }\n  }\n}\n```\n\nWe've added a few tags to the whitelist to make sure that purgecss doesn't remove any styles that apply to them.\n\nWe should now have tailwindcss up and running with purgecss to remove any unused styles when we come round to running `npm run generate`.\n\nFire up the dev server with `npm run dev` just to make sure everything still works.\n\n## Updating our default layout\n\nInside the components directory create three new files; `PageHeader.vue` `PageNav.vue` and `PageFooter.vue` with the following contents respectively:\n\n```html\n\u003Ctemplate>\n  \u003Cheader class=\"text-center\">\n    \u003Ca class=\"text-gray-800 text-3xl font-bold\" href=\"\u002F\">\n      \u003Ch1>\n        Static Blog\n      \u003C\u002Fh1>\n    \u003C\u002Fa>\n  \u003C\u002Fheader>\n\u003C\u002Ftemplate>\n```\n\n```html\n\u003Ctemplate>\n  \u003Cnav class=\"text-center my-4\">\n    \u003Ca href=\"\u002F\" class=\"p-2 text-sm sm:text-lg inline-block text-gray-800 hover:underline\">Blog\u003C\u002Fa>\n    \u003Ca href=\"\u002Fabout\" class=\"p-2 text-sm sm:text-lg p-2 inline-block text-gray-800 hover:underline\">About\u003C\u002Fa>\n  \u003C\u002Fnav>\n\u003C\u002Ftemplate>\n```\n\n```html\n\u003Ctemplate>\n  \u003Cfooter class=\"flex justify-center my-4\">\n    \u003Cdiv class=\"text-gray-800 text-sm\">\n      A static blog built with Nuxt.js, Tailwindcss and Cockpit.\n    \u003C\u002Fdiv>\n  \u003C\u002Ffooter>\n\u003C\u002Ftemplate>\n```\n\nNow go over to the layouts directory and update `default.vue` so that it looks like this:\n\n```html\n\u003Ctemplate>\n  \u003Cdiv class=\"flex flex-row justify-center w-screen\">\n    \u003Cdiv class=\"overflow-hidden content flex flex-col p-4 md:p-8\">\n      \u003Cpage-header\u002F>\n      \u003Cpage-nav\u002F>\n      \u003Cnuxt\u002F>\n      \u003Cpage-footer\u002F>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n```\n```javascript\n\u003Cscript>\nimport PageHeader from '~\u002Fcomponents\u002FPageHeader.vue'\nimport PageNav from '~\u002Fcomponents\u002FPageNav.vue'\nimport PageFooter from '~\u002Fcomponents\u002FPageFooter.vue'\n\nexport default {\n  components: {\n    PageHeader,\n    PageNav,\n    PageFooter\n  }\n}\n\u003C\u002Fscript>\n```\n\nDelete any of the default styles that were there as we won't be needing them.\n\nAlso add the following style to our main.css file underneath @tailwind components:\n\n```css\n.content {\n  width: 50rem;\n}\n\n.markdown p {\n  @apply mt-0 mb-6;\n}\n\n.markdown ul {\n  @apply mb-6;\n}\n\npre {\n  @apply my-8;\n}\n```\n\nNow we just need to update index.vue in the pages directory.\n\n## Fetching and displaying our posts\n\nMake sure that you have the axios module loaded correctly in your `nuxt.config.js`\n\n```js\n\u002F*\n** Nuxt.js modules\n*\u002F\nmodules: [\n  \u002F\u002F Doc: https:\u002F\u002Fgithub.com\u002Fnuxt-community\u002Faxios-module#usage\n  '@nuxtjs\u002Faxios'\n],\n```\n\nIn the index.vue page update the file so that it resembles the following:\n\n```html\n\u003Ctemplate>\n  \u003Csection>\n    \u003Cdiv class=my-8>\n      \u003Cul class=\"flex flex-col w-full p-0\">\n        \u003Cli class=\"mb-6 w-full\" v-for=\"(post, key) in posts\" :key=\"key\">\n          \u003Cdiv class=\"text-gray-600 font-bold text-sm tracking-wide\">\n            \u003Ca v-for=\"tag in post.tags\" :key=\"tag\" :href=\"'\u002Fcategory\u002F'+tag\" class=\"ml-1\">{{ tag }}\u003C\u002Fa>\n          \u003C\u002Fdiv>\n\n          \u003Ca :href=\"'\u002F'+post.title_slug\">\n            \u003Ch2 class=\"my-2 text-gray-800 text-lg lg:text-xl font-bold\">\n              {{ post.title }}\n            \u003C\u002Fh2>\n          \u003C\u002Fa>\n\n          \u003Cdiv class=\"page-content hidden md:block text-base mb-2\" v-html=\"post.excerpt\">\n          \u003C\u002Fdiv>\n          \u003Ca class=\"text-sm text-blue-400 no-underline\" :href=\"'\u002F'+post.title_slug\">\n            Read more\n          \u003C\u002Fa>\n        \u003C\u002Fli>\n      \u003C\u002Ful>\n    \u003C\u002Fdiv>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n```\n```javascript\n\u003Cscript>\nexport default {\n  async asyncData ({ app }) {\n    const { data } = await app.$axios.post(process.env.POSTS_URL,\n    JSON.stringify({\n        filter: { published: true },\n        sort: {_created:-1},\n        populate: 1\n      }),\n    {\n      headers: { 'Content-Type': 'application\u002Fjson' }\n    })\n\n    return { posts: data.entries }\n  }\n}\n\u003C\u002Fscript>\n```\n\nNuxt includes the asyncData method which can be called on the server side before the component data has been set. You can read more about this method here - [https:\u002F\u002Fnuxtjs.org\u002Fguide\u002Fasync-data](https:\u002F\u002Fnuxtjs.org\u002Fguide\u002Fasync-data)\n\nWhat we are doing is retrieving the posts from Cockpit and then setting these as the component data in a posts variable.\n\nIf you visit the site now at `http:\u002F\u002Flocalhost:3000` you should see the post entries you've added from Cockpit.\n\nYou should now have something that looks like this.\n\n\u003Cdiv class=\"blog-image\">\n\n![satic-blog-layout](\u002Fimages\u002Fposts\u002F5b28bf9fc1f0cstatic-blog-layout.png)\n\u003C\u002Fdiv>\n\nIn the next part we'll look at generating our dynamic routes in `nuxt.config.js` for our individual blog posts based on their `title slug` and also setting up our category page to display posts depending on their tags.\n\nYou can find Part 2 here - [Part 2: Dynamic Routes](https:\u002F\u002Fwillbrowning.me\u002Fbuilding-a-static-blog-with-nuxt-js-and-cockpit-headless-cms-part-2-dynamic-routes)","\u002Fimages\u002Fposts\u002F5b265ae2d783ccockpit-collections.png",1529151293,1570695290,1789647036973]