diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f27e9a9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +# Unix-style newlines with a newline ending every file +end_of_line = lf +insert_final_newline = true + +[*.js] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..262d6bd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# Set default behavior to automatically normalize line endings. +* text=auto + +# Force bash scripts to always use LF line endings so that if a repo is accessed +# in Unix via a file share from Windows, the scripts will work. +*.sh text eol=lf + +# Force batch scripts to always use CRLF line endings so that if a repo is accessed +# in Windows via a file share from Linux, the scripts will work. +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf + +# Denote all files that are truly binary and should not be modified. +*.png binary +*.jpg binary +*.ico binary diff --git a/.github/workflows/pages-deploy.yml b/.github/workflows/pages-deploy.yml new file mode 100644 index 0000000..cc28f99 --- /dev/null +++ b/.github/workflows/pages-deploy.yml @@ -0,0 +1,73 @@ +name: "Build and Deploy" +on: + push: + branches: + - main + - master + paths-ignore: + - .gitignore + - README.md + - LICENSE + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + # submodules: true + # If using the 'assets' git submodule from Chirpy Starter, uncomment above + # (See: https://github.com/cotes2020/chirpy-starter/tree/main/assets) + + - name: Setup Pages + id: pages + uses: actions/configure-pages@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3 + bundler-cache: true + + - name: Build site + run: bundle exec jekyll b -d "_site${{ steps.pages.outputs.base_path }}" + env: + JEKYLL_ENV: "production" + + - name: Test site + run: | + bundle exec htmlproofer _site \ + \-\-disable-external \ + \-\-ignore-urls "/^http:\/\/127.0.0.1/,/^http:\/\/0.0.0.0/,/^http:\/\/localhost/" + + - name: Upload site artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "_site${{ steps.pages.outputs.base_path }}" + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21524fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Bundler cache +.bundle +vendor +Gemfile.lock + +# Jekyll cache +.jekyll-cache +.jekyll-metadata +_site + +# RubyGems +*.gem + +# NPM dependencies +node_modules +package-lock.json + +# IDE configurations +.idea +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +!.vscode/tasks.json + +# Misc +_app + +# Link Checker +link-checker.json +error.txt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..58062c5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "assets/lib"] + path = assets/lib + url = https://github.com/cotes2020/chirpy-static-assets.git diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..4f2b762 --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +josh-ops.com \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..66f9337 --- /dev/null +++ b/Gemfile @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "html-proofer", "~> 5.0", group: :test + +platforms :mingw, :x64_mingw, :mswin, :jruby do + gem "tzinfo", ">= 1", "< 3" + gem "tzinfo-data" +end + +gem "wdm", "~> 0.1.1", :platforms => [:mingw, :x64_mingw, :mswin] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97f43b6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Josh Johanning + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b629b78 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# josh-ops.com + +## Overview + +A DevOps Blog - Blogging about GitHub and Azure DevOps practices, tips, scripts, and my continuous improvement DevOps journey. + +[**josh-ops.com →**](https://josh-ops.com) + +[![Build and Deploy](https://github.com/joshjohanning/joshjohanning.github.io/actions/workflows/pages-deploy.yml/badge.svg?branch=main)](https://github.com/joshjohanning/joshjohanning.github.io/actions/workflows/pages-deploy.yml) + +## Theme Source + +Chirpy: + +* [GitHub repo](https://github.com/cotes2020/jekyll-theme-chirpy) +* [Example and tips/best practices](https://chirpy.cotes.page/) +* [Upgrading](#upgrading-the-theme) (using `git cherry-pick` to pull changes from upstream) + +## Comment System + +- [Utterances](https://utteranc.es/) (configured [directly in Chirpy](https://github.com/joshjohanning/joshjohanning.github.io/blob/a54c9633e6cab32fd30dc69afc9ffd74857cbd8a/_config.yml#L84-L92)) which uses GitHub issues for post comments + +## Deviations from Chirpy + +### Adding Speaking tab + +- Added a [speaking tab](https://josh-ops.com/speaking/) to capture my speaking engagements +- [Used an icon](https://github.com/joshjohanning/joshjohanning.github.io/blob/ab7bb6e3842189adf1dccc909e1e77b86b625d0a/_tabs/speaking.md?plain=1#L3) from [fontawesome](https://fontawesome.com/v4/icons/) for the link in the sidebar + +### Light Mode Sidebar Background Color + +- For my implementation of Chirpy v4.3.0 to v6.1.0, I [reverted](https://github.com/joshjohanning/joshjohanning.github.io/pull/8) the light mode sidebar background color to the pre-v4.3.0 color (blue/purple) +- When I updated from [Chirpy v6.1.0 to v6.3.0](https://github.com/joshjohanning/joshjohanning.github.io/pull/30), I decided to use the latest upstream values for the light mode sidebar background color (light gray) + +#### Changelog + +- See: [#8](https://github.com/joshjohanning/joshjohanning.github.io/pull/8) where I reverted to the pre-v4.3.0 color (blue/purple) +- In [#27](https://github.com/joshjohanning/joshjohanning.github.io/pull/27), I updated the `sidebar-active-color` property to the latest upstream value +- In [#30](https://github.com/joshjohanning/joshjohanning.github.io/pull/30), I reverted to the latest upstream values for light mode, which included a change to the `sidebar-bg` and `sidebar-muted-color` properties to bring in the light gray sidebar background color + +### Preview Images + +- Chirpy [v5.4.0](https://github.com/cotes2020/jekyll-theme-chirpy/commit/4b6ccbcbccce27b9fcb035812efefe4eb69301cf) introduced a strict `40 / 21` (`1:91:1`) aspect ratio requirement for post's preview images such that they would be cropped if you used a different aspect ratio +- In prior versions, I removed this code so that the post's preview images would still render with their original size +- In June 2023, I updated most of the preview images with the new aspect ratio and [brought back](https://github.com/joshjohanning/joshjohanning.github.io/commit/1920dc7d98cbe11a6882ae0ec067fabccd64426b) preview images to the home page, but I still left out the `40 / 21;` line from the `post.scss` file to account for the images that weren't updated +- In November 2023, I updated to Chirpy v6.2.3 and the preview image logic was moved to `commons.scss`; removed the `40 / 21;` line for the non-updated images + +#### Changelog + +- Upstream commit introducing change: [4b6ccbc](https://github.com/cotes2020/jekyll-theme-chirpy/commit/4b6ccbcbccce27b9fcb035812efefe4eb69301cf) (Chirpy [v5.4.0](https://github.com/cotes2020/jekyll-theme-chirpy/releases/tag/v5.4.0)) +- My changes so that preview image still shimmers before loading, but no image cropping: [b282712^..bb1dc1f](https://github.com/joshjohanning/joshjohanning.github.io/compare/b282712087028da95e292e3159d20cdf63d59feb^..bb1dc1f1bdbba4ee7d62858d834e0ca19f7745db) + - Really only need to get rid of `aspect-ratio: 40 / 21;` line +- June 20, 2023: [Updated](https://github.com/joshjohanning/joshjohanning.github.io/commit/af83c7019c5783f70d5e725991097a7217a6658a) most of the post images to reflect the `1.91:1` aspect ratio since that's the ratio the [home page expects](https://github.com/joshjohanning/joshjohanning.github.io/commit/1920dc7d98cbe11a6882ae0ec067fabccd64426b) for the post preview images + - I still left out the `40 / 21;` line in the `post.scss` file for the images I didn't update to show the original image size on the post page +- November 1, 2023: In Chirpy [v6.2.3](https://github.com/joshjohanning/joshjohanning.github.io/pull/30), the preview image logic was moved to `commons.scss`; removed the `40 / 21;` line for the non-updated images + +## Upgrading the Theme + +Since we aren't using the theme gem (so we can do customizations), we have to do it the old-fashioned way: + +1. Ensure chirpy is set as a remote: `git remote add chirpy https://github.com/cotes2020/jekyll-theme-chirpy.git` +2. Ensure you have the latest upstream commit: `git fetch chirpy` +3. Compare the upstream [releases](https://github.com/cotes2020/jekyll-theme-chirpy/releases) and [commits](https://github.com/cotes2020/jekyll-theme-chirpy/commits/master) to find the first and last release/commit in the range you want to update + - Recommendation is to use release tag milestones instead of loose commits that aren't part of a release yet + - You can use this [link](https://github.com/cotes2020/jekyll-theme-chirpy/compare/a887f1d^..602e984) to compare the changes between two commits in GitHub (same for [releases](https://github.com/cotes2020/jekyll-theme-chirpy/compare/v5.6.0..v5.6.1)) +4. Start the `git cherry-pick`: + - To cherry-pick between a range of release tags (more common): `git cherry-pick "v5.6.0..v5.6.1" -m 1` + - To cherry-pick a single commit (not as common): `git cherry-pick a887f1d -m 1` + - If getting GPG errors, modify the local git config: `git config commit.gpgsign false`, but modify it back to `true` after you are done cherry-picking and rebasing (before amending commit) +5. Review merge conflicts - use a combination of `git cherry-pick --skip` (for when readme/starter posts are updated) and `cherry-pick --continue` (to continue after you resolve real merge conflicts) +6. Starting in Chirpy v5.6.0, run: `npm run build && git add assets/js/dist _sass/dist -f && git commit -m "update js assets"` ([docs](https://github.com/cotes2020/jekyll-theme-chirpy/wiki/Upgrade-Guide#upgrade-the-fork)) + - You can also run a command that's referenced in the `init.sh` to remove this from `.gitignore`: `sed -i '' "/.*\/dist$/d" .gitignore` +7. Rebase the number of commits you just brought in (you should see icon in VS Code): `git rebase -i HEAD~16` + - Leave the top commit as `pick` but change the rest to `squash` + - Update the commit message as appropriate +8. Pay close attention to the terminal output as to which new files are being created and if they should be deleted (new files show up as `create mode 100644 file.ext`) + - For example, we wouldn't want to commit a GitHub workflow or issue template that wasn't needed here + - If there are new files that we don't want to track, delete the files, commit, and run another rebase `git rebase -i HEAD~2` + - This command can help with tracking new files in the most recent commit: `git diff-tree --compact-summary -r HEAD --diff-filter=A` +9. Ensure commit signing is enabled: `git config commit.gpgsign true` +10. Update author and commit time: `git commit --amend --author "Josh Johanning " --date=now -S` +11. [Test changes locally before pushing](#building--testing-locally) + +## Building / Testing Locally + +```sh +bundle install +npm i && npm run build +bundle exec jekyll s +``` + +### Additional build notes + +#### On macOS + +Check ruby version: `ruby -v` (if ruby 2.6.10p210, then you need to upgrade to 3.0.0+): + +1. Install Ruby via Homebrew: `brew install ruby` (can also use [`rvm`](https://rvm.io/rvm/install)) +2. Make sure the new Ruby is in your path: `export PATH="/opt/homebrew/opt/ruby/bin:$PATH"' >> ~/.zshrc` +3. Check ruby version: `ruby -v` (should be 3.0.0+) +4. Build and serve the site as normal + +#### On Codespaces + +If seeing a `racc 1.6.2` permission error, run: + +```sh +sudo chown -R codespace /usr/local/rvm/gems/ruby-3.1.4/extensions/x86_64-linux/3.1.0 +bundle install +``` diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..409421e --- /dev/null +++ b/_config.yml @@ -0,0 +1,225 @@ +# The Site Configuration + +# Import the theme +theme: jekyll-theme-chirpy + +# The language of the webpage › http://www.lingoes.net/en/translator/langcode.htm +# If it has the same name as one of the files in folder `_data/locales`, the layout language will also be changed, +# otherwise, the layout language will use the default value of 'en'. +lang: en + +# Change to your timezone › https://kevinnovak.github.io/Time-Zone-Picker +timezone: America/Chicago + +# jekyll-seo-tag settings › https://github.com/jekyll/jekyll-seo-tag/blob/master/docs/usage.md +# ↓ -------------------------- + +title: josh-ops # the main title + +tagline: A DevOps Blog # it will display as the sub-title + +description: >- # used by seo meta and the atom feed + Blogging about Azure DevOps and GitHub practices, tips, and my continuous improvement DevOps journey. + +# Fill in the protocol & hostname for your site. +# e.g. 'https://username.github.io', note that it does not end with a '/'. +url: "https://josh-ops.com" + +github: + username: joshjohanning # change to your github username + +twitter: + username: jjjettrain # change to your twitter username + +social: + # Change to your full name. + # It will be displayed as the default author of the posts and the copyright owner in the Footer + name: Josh Johanning + email: jjohanning@uwalumni.com # change to your email address + links: + # The first element serves as the copyright owner's link + - https://github.com/joshjohanning # change to your github homepage + - https://twitter.com/jjjettrain # change to your twitter homepage + # Uncomment below to add more social links + # - https://www.facebook.com/username + - https://www.linkedin.com/in/joshua-johanning/ + +# Site Verification Settings +webmaster_verifications: + google: eTIC71VhPBtwll8rNv7qlXud1yJh6E88No39MdcM_MI # fill in your Google verification code + bing: # fill in your Bing verification code + alexa: # fill in your Alexa verification code + yandex: # fill in your Yandex verification code + baidu: # fill in your Baidu verification code + facebook: # fill in your Facebook verification code + +# ↑ -------------------------- +# The end of `jekyll-seo-tag` settings + +# Web Analytics Settings +analytics: + google: + id: "G-GE92ZJY22K" # fill in your Google Analytics ID + goatcounter: + id: # fill in your GoatCounter ID + umami: + id: # fill in your Umami ID + domain: # fill in your Umami domain + matomo: + id: # fill in your Matomo ID + domain: # fill in your Matomo domain + cloudflare: + id: # fill in your Cloudflare Web Analytics token + fathom: + id: # fill in your Fathom Site ID + +# Page views settings +pageviews: + provider: # now only supports 'goatcounter' + +# Prefer color scheme setting. +# +# Note: Keep empty will follow the system prefer color by default, +# and there will be a toggle to switch the theme between dark and light +# on the bottom left of the sidebar. +# +# Available options: +# +# light — Use the light color scheme +# dark — Use the dark color scheme +# +theme_mode: # [light | dark] + +# The CDN endpoint for media resources. +# Notice that once it is assigned, the CDN url +# will be added to all media resources (site avatar, posts' images, audio and video files) paths starting with '/' +# +# e.g. 'https://cdn.com' +cdn: "" + +# the avatar on sidebar, support local or CORS resources +avatar: /assets/img/sample/headshot.png + +# The URL of the site-wide social preview image used in SEO `og:image` meta tag. +# It can be overridden by a customized `page.image` in front matter. +social_preview_image: # string, local or CORS resources + +# boolean type, the global switch for TOC in posts. +toc: true + +comments: + # Global switch for the post-comment system. Keeping it empty means disabled. + provider: utterances # [disqus | utterances | giscus] + # The provider options are as follows: + disqus: + shortname: # fill with the Disqus shortname. › https://help.disqus.com/en/articles/1717111-what-s-a-shortname + # utterances settings › https://utteranc.es/ + utterances: + repo: joshjohanning/joshjohanning.github.io + issue_term: title # < url | pathname | title | ...> + # Giscus options › https://giscus.app + giscus: + repo: # / + repo_id: + category: + category_id: + mapping: # optional, default to 'pathname' + strict: # optional, default to '0' + input_position: # optional, default to 'bottom' + lang: # optional, default to the value of `site.lang` + reactions_enabled: # optional, default to the value of `1` + +# Self-hosted static assets, optional › https://github.com/cotes2020/chirpy-static-assets +assets: + self_host: + enabled: # boolean, keep empty means false + # specify the Jekyll environment, empty means both + # only works if `assets.self_host.enabled` is 'true' + env: # [development | production] + +pwa: + enabled: true # The option for PWA feature (installable) + cache: + enabled: true # The option for PWA offline cache + # Paths defined here will be excluded from the PWA cache. + # Usually its value is the `baseurl` of another website that + # shares the same domain name as the current website. + deny_paths: + # - "/example" # URLs match `/example/*` will not be cached by the PWA + +paginate: 10 + +# The base URL of your site +baseurl: "" + +# ------------ The following options are not recommended to be modified ------------------ + +kramdown: + footnote_backlink: "↩︎" + syntax_highlighter: rouge + syntax_highlighter_opts: # Rouge Options › https://github.com/jneen/rouge#full-options + css_class: highlight + # default_lang: console + span: + line_numbers: false + block: + line_numbers: true + start_line: 1 + +collections: + tabs: + output: true + sort_by: order + +defaults: + - scope: + path: "" # An empty string here means all files in the project + type: posts + values: + layout: post + comments: true # Enable comments in posts. + toc: true # Display TOC column in posts. + # DO NOT modify the following parameter unless you are confident enough + # to update the code of all other post links in this project. + permalink: /posts/:title/ + - scope: + path: _drafts + values: + comments: false + - scope: + path: "" + type: tabs # see `site.collections` + values: + layout: page + permalink: /:title/ + +sass: + style: compressed + +compress_html: + clippings: all + comments: all + endings: all + profile: false + blanklines: false + ignore: + envs: [development] + +exclude: + - "*.gem" + - "*.gemspec" + - docs + - tools + - README.md + - LICENSE + - "*.config.js" + - package*.json + +jekyll-archives: + enabled: [categories, tags] + layouts: + category: category + tag: tag + permalinks: + tag: /tags/:name/ + category: /categories/:name/ diff --git a/_data/authors.yml b/_data/authors.yml new file mode 100644 index 0000000..e5f1f72 --- /dev/null +++ b/_data/authors.yml @@ -0,0 +1,12 @@ +## Template › https://github.com/jekyll/jekyll-seo-tag/blob/master/docs/advanced-usage.md#setting-author-url +# ------------------------------------- +# {author_id}: +# name: {full name} +# twitter: {twitter_of_author} +# url: {homepage_of_author} +# ------------------------------------- + +Josh Johanning: + name: Josh Johanning + twitter: jjjettrain + url: https://github.com/joshjohanning/ diff --git a/_data/contact.yml b/_data/contact.yml new file mode 100644 index 0000000..1967901 --- /dev/null +++ b/_data/contact.yml @@ -0,0 +1,40 @@ +# The contact options. + +- type: github + icon: "fab fa-github" + +# - type: twitter +# icon: "fa-brands fa-x-twitter" + +- type: email + icon: "fas fa-envelope" + noblank: true # open link in current tab + +- type: rss + icon: "fas fa-rss" + noblank: true +# Uncomment and complete the url below to enable more contact options +# +# - type: mastodon +# icon: "fab fa-mastodon" # icons powered by +# url: "" # Fill with your Mastodon account page, rel="me" will be applied for verification +# +- type: linkedin + icon: "fab fa-linkedin" # icons powered by + url: "https://www.linkedin.com/in/joshua-johanning/" # Fill with your Linkedin homepage +# +# - type: stack-overflow +# icon: 'fab fa-stack-overflow' +# url: '' # Fill with your stackoverflow homepage +# +# - type: bluesky +# icon: 'fa-brands fa-bluesky' +# url: '' # Fill with your Bluesky profile link +# +# - type: reddit +# icon: 'fa-brands fa-reddit' +# url: '' # Fill with your Reddit profile link +# +# - type: threads +# icon: 'fa-brands fa-threads' +# url: '' # Fill with your Threads profile link diff --git a/_data/locales/ar.yml b/_data/locales/ar.yml new file mode 100644 index 0000000..a79e020 --- /dev/null +++ b/_data/locales/ar.yml @@ -0,0 +1,91 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: منشور + category: فئة + tag: وسم + +# The tabs of sidebar +tabs: + # format: : + home: الرئيسية + categories: الفئات + tags: الوسوم + archives: الأرشيف + about: حول + +# the text displayed in the search bar & search results +search: + hint: بحث + cancel: إلغاء + no_results: نأسف! لا يوجد نتائج. + +panel: + lastmod: المحدثة مؤخرا + trending_tags: الوسوم الشائعة + toc: محتويات + +copyright: + # Shown at the bottom of the post + license: + template: هذا المنشور تحت ترخيص :LICENSE_NAME بواسطة المؤلف. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: بعض الحقوق محفوظة. + verbose: >- + ما لم يذكر خلاف ذلك ، يتم ترخيص منشورات المدونة على هذا الموقع + بموجب ترخيص Creative Commons Attribution 4.0 International (CC BY 4.0) من قبل المؤلف. + +meta: باستخدام :PLATFORM السمة :THEME + +not_found: + statement: عذرا, الرابط التالي غير صالح أو انه يشير إلى صفحة غير موجودة. + +notification: + update_found: يتوفر اصدار جديد للمحتوى. + update: تحديث + +# ----- Posts related labels ----- + +post: + written_by: بواسطة + posted: نشّر + updated: حدّث + words: كلمات + pageview_measure: مشاهدات + read_time: + unit: دقيقة + prompt: قراءة + relate_posts: إقرأ المزيد + share: شارك + button: + next: الأجدد + previous: الأقدم + copy_code: + succeed: تم النسخ! + share_link: + title: أنسخ الرابط + succeed: تم نسخ الرابط بنجاح! + +# Date time format. +# See: , +df: + post: + strftime: "%b %e, %Y" + dayjs: "ll" + archives: + strftime: "%b" + dayjs: "MMM" + +# categories page +categories: + category_measure: + singular: فئة + plural: فئات + post_measure: + singular: منشور + plural: منشورات diff --git a/_data/locales/bg-BG.yml b/_data/locales/bg-BG.yml new file mode 100644 index 0000000..3fb060f --- /dev/null +++ b/_data/locales/bg-BG.yml @@ -0,0 +1,81 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Публикация + category: Категория + tag: Таг + +# The tabs of sidebar +tabs: + # format: : + home: Начало + categories: Категории + tags: Тагове + archives: Архив + about: За мен + +# the text displayed in the search bar & search results +search: + hint: търси + cancel: Отмени + no_results: Упс! Не са намерени резултати. + +panel: + lastmod: Наскоро обновени + trending_tags: Популярни тагове + toc: Съдържание + +copyright: + # Shown at the bottom of the post + license: + template: Тази публикация е лицензирана под :LICENSE_NAME от автора. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Някои права запазени. + verbose: >- + Освен ако не е посочено друго, публикациите в блога на този сайт са лицензирани + под лиценза Creative Commons Attribution 4.0 (CC BY 4.0) от автора. + +meta: Създадено чрез :PLATFORM и :THEME тема + +not_found: + statement: Съжалявам, но на този URL адрес няма налично съдържание. + +notification: + update_found: Налична е нова версия на съдържанието. + update: Обнови + +# ----- Posts related labels ----- + +post: + written_by: Автор + posted: Публикувана + updated: Обновена + words: думи + pageview_measure: преглеждания + read_time: + unit: мин + prompt: четиво + relate_posts: Още за четене + share: Споделете + button: + next: По-нови + previous: По-стари + copy_code: + succeed: Копирано! + share_link: + title: Копирай линк + succeed: Линкът е копиран успешно! + +# categories page +categories: + category_measure: + singular: категория + plural: категории + post_measure: + singular: публикация + plural: публикации diff --git a/_data/locales/cs-CZ.yml b/_data/locales/cs-CZ.yml new file mode 100644 index 0000000..cf93f61 --- /dev/null +++ b/_data/locales/cs-CZ.yml @@ -0,0 +1,89 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Příspěvek + category: Kategorie + tag: Štítek + +# The tabs of sidebar +tabs: + # format: : + home: Domů + categories: Kategorie + tags: Štítky + archives: Archivy + about: O mně + +# the text displayed in the search bar & search results +search: + hint: hledat + cancel: Zrušit + no_results: Ups! Žádný výsledek nenalezen. + +panel: + lastmod: Nedávno aktualizováno + trending_tags: Trendy štítky + toc: Obsah + +copyright: + # Shown at the bottom of the post + license: + template: Tento příspěvek je licencován pod :LICENSE_NAME autorem. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Některá práva vyhrazena. + verbose: >- + Pokud není uvedeno jinak, jsou příspěvky na tomto webu licencovány + pod licencí Creative Commons Attribution 4.0 International (CC BY 4.0) Licence autora. + +meta: Použití :PLATFORM s motivem :THEME + +not_found: + statement: Omlouváme se, adresu URL jsme špatně umístili nebo odkazuje na něco, co neexistuje. + +notification: + update_found: Je k dispozici nová verze obsahu. + update: Aktualizace + +# ----- Posts related labels ----- + +post: + written_by: Od + posted: Zveřejněno + updated: Aktualizováno + words: slova + pageview_measure: zhlednutí + read_time: + unit: minut + prompt: čtení + relate_posts: Další čtení + share: Sdílet + button: + next: Novější + previous: Starší + copy_code: + succeed: Zkopírováno! + share_link: + title: Kopírovat odkaz + succeed: Zkopírováno! + +# Date time format. +# See: , +df: + post: + strftime: "%b %e, %Y" + dayjs: "ll" + archives: + strftime: "%b" + dayjs: "MMM" + +# categories page +categories: + category_measure: kategorie + post_measure: + singular: příspěvěk + plural: příspěvky diff --git a/_data/locales/de-DE.yml b/_data/locales/de-DE.yml new file mode 100644 index 0000000..6c9d91d --- /dev/null +++ b/_data/locales/de-DE.yml @@ -0,0 +1,87 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Eintrag + category: Kategorie + tag: Tag + +# The tabs of sidebar +tabs: + # format: : + home: Startseite + categories: Kategorien + tags: Tags + archives: Archiv + about: Über + +# the text displayed in the search bar & search results +search: + hint: Suche + cancel: Abbrechen + no_results: Ups! Keine Einträge gefunden. + +panel: + lastmod: Kürzlich aktualisiert + trending_tags: Beliebte Tags + toc: Inhalt + +copyright: + # Shown at the bottom of the post + license: + template: Dieser Eintrag ist vom Autor unter :LICENSE_NAME lizensiert. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Einige Rechte vorbehalten. + verbose: >- + Alle Einträge auf dieser Seite stehen, soweit nicht anders angegeben, unter der Lizenz Creative Commons Attribution 4.0 (CC BY 4.0). + +meta: Powered by :PLATFORM with :THEME theme + +not_found: + statement: Entschuldigung, dieser Link verweist auf keine vorhandene Ressource. + +notification: + update_found: Eine neue Version ist verfügbar. + update: Neue Version + +# ----- Posts related labels ----- + +post: + written_by: Von + posted: Veröffentlicht + updated: Aktualisiert + words: Wörter + pageview_measure: Aufrufe + read_time: + unit: Minuten + prompt: Lesezeit + relate_posts: Weiterlesen + share: Teilen + button: + next: Nächster Eintrag + previous: Eintrag vorher + copy_code: + succeed: Kopiert! + share_link: + title: Link kopieren + succeed: Link erfolgreich kopiert! + +# Date time format. +# See: , +df: + post: + strftime: "%d.%m.%Y" + dayjs: "DD.MM.YYYY" + +# categories page +categories: + category_measure: + singular: Kategorie + plural: Kategorien + post_measure: + singular: Eintrag + plural: Einträge diff --git a/_data/locales/el-GR.yml b/_data/locales/el-GR.yml new file mode 100644 index 0000000..b6d2a86 --- /dev/null +++ b/_data/locales/el-GR.yml @@ -0,0 +1,91 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Δημοσίευση + category: Κατηγορία + tag: Ετικέτα + +# The tabs of sidebar +tabs: + # format: : + home: Home + categories: Κατηγορίες + tags: Ετικέτες + archives: Αρχεία + about: Σχετικά + +# the text displayed in the search bar & search results +search: + hint: αναζήτηση + cancel: Ακύρωση + no_results: Oops! Κανένα αποτέλεσμα δεν βρέθηκε. + +panel: + lastmod: Σχετικά ενημερωμένα + trending_tags: Ετικέτες τάσης + toc: Περιεχόμενα + +copyright: + # Shown at the bottom of the post + license: + template: Η δημοσίευση αυτή βρίσκεται υπο την άδεια :LICENSE_NAME Greekforce1821. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Ορισμένα δικαιώματα reserved. + verbose: >- + Εκτός αλλού ή οπουδήποτε αλλού, τα blog posts σε αυτήν την σελίδα βρίσκονται υπο την άδεια + Creative Commons Attribution 4.0 International (CC BY 4.0) του δημιουργού. + +meta: Αξιοποιώντας την :PLATFORM theme :THEME + +not_found: + statement: Συγνώμη, έχουμε τοποθετήσει λάθος αυτήν την διεύθυνση URL ή υποδεικνύει κάτι που δεν υπάρχει. + +notification: + update_found: Υπάρχει διαθέσιμη μια νέα έκδοση του περιεχομένου. + update: Ενημέρωση + +# ----- Posts related labels ----- + +post: + written_by: Από + posted: Δημοσιεύθηκε + updated: Ενημερώθηκε + words: λέξεις + pageview_measure: προβολές + read_time: + unit: Λεπτά + prompt: διαβάσματος + relate_posts: Περισσότερα + share: Κοινοποιήστε + button: + next: Νεότερα + previous: Παλαιότερα + copy_code: + succeed: Αντιγράφθηκε! + share_link: + title: Αντιγραφή συνδέσμου + succeed: Η διεύθυνση αντιγράφθηκε με επιτυχία! + +# Date time format. +# See: , +df: + post: + strftime: "%b %e, %Y" + dayjs: "ll" + archives: + strftime: "%b" + dayjs: "MMM" + +# categories page +categories: + category_measure: + singular: Κατηγορία + plural: Κατηγορίες + post_measure: + singular: Δημοσίευση + plural: Δημοσιεύσεις diff --git a/_data/locales/en.yml b/_data/locales/en.yml new file mode 100644 index 0000000..152d090 --- /dev/null +++ b/_data/locales/en.yml @@ -0,0 +1,91 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Post + category: Category + tag: Tag + +# The tabs of sidebar +tabs: + # format: : + home: Home + categories: Categories + tags: Tags + archives: Archives + about: About + +# the text displayed in the search bar & search results +search: + hint: search + cancel: Cancel + no_results: Oops! No results found. + +panel: + lastmod: Recently Updated + trending_tags: Trending Tags + toc: Contents + +copyright: + # Shown at the bottom of the post + license: + template: This post is licensed under :LICENSE_NAME by the author. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Some rights reserved. + verbose: >- + Except where otherwise noted, the blog posts on this site are licensed + under the Creative Commons Attribution 4.0 International (CC BY 4.0) License by the author. + +meta: Using the :THEME theme for :PLATFORM. + +not_found: + statement: Sorry, we've misplaced that URL or it's pointing to something that doesn't exist. + +notification: + update_found: A new version of content is available. + update: Update + +# ----- Posts related labels ----- + +post: + written_by: By + posted: Posted + updated: Updated + words: words + pageview_measure: views + read_time: + unit: min + prompt: read + relate_posts: Further Reading + share: Share + button: + next: Newer + previous: Older + copy_code: + succeed: Copied! + share_link: + title: Copy link + succeed: Link copied successfully! + +# Date time format. +# See: , +df: + post: + strftime: "%b %e, %Y" + dayjs: "ll" + archives: + strftime: "%b" + dayjs: "MMM" + +# categories page +categories: + category_measure: + singular: category + plural: categories + post_measure: + singular: post + plural: posts diff --git a/_data/locales/es-ES.yml b/_data/locales/es-ES.yml new file mode 100644 index 0000000..8f8d149 --- /dev/null +++ b/_data/locales/es-ES.yml @@ -0,0 +1,77 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Entrada + category: Categoría + tag: Etiqueta + +# The tabs of sidebar +tabs: + # format: : + home: Inicio + categories: Categorías + tags: Etiquetas + archives: Archivo + about: Acerca de + +# the text displayed in the search bar & search results +search: + hint: Buscar + cancel: Cancelar + no_results: ¡Oops! No se encuentran resultados. + +panel: + lastmod: Actualizado recientemente + trending_tags: Etiquetas populares + toc: Contenido + +copyright: + # Shown at the bottom of the post + license: + template: Esta entrada está licenciada bajo :LICENSE_NAME por el autor. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Algunos derechos reservados. + verbose: >- + Salvo que se indique explícitamente, las entradas de este blog están licenciadas + bajo la Creative Commons Attribution 4.0 International (CC BY 4.0) License por el autor. + +meta: Hecho con :PLATFORM usando el tema :THEME + +not_found: + statement: Lo sentimos, hemos perdido esa URL o apunta a algo que no existe. + +notification: + update_found: Hay una nueva versión de contenido disponible. + update: Actualizar + +# ----- Posts related labels ----- + +post: + written_by: Por + posted: Publicado + updated: Actualizado + words: palabras + pageview_measure: visitas + read_time: + unit: min + prompt: " de lectura" + relate_posts: Lecturas adicionales + share: Compartir + button: + next: Nuevo + previous: Anterior + copy_code: + succeed: ¡Copiado! + share_link: + title: Copiar enlace + succeed: ¡Enlace copiado! + +# categories page +categories: + category_measure: categorias + post_measure: entradas diff --git a/_data/locales/fi-FI.yml b/_data/locales/fi-FI.yml new file mode 100644 index 0000000..60c9862 --- /dev/null +++ b/_data/locales/fi-FI.yml @@ -0,0 +1,90 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Julkaisu + category: Kateogoria + tag: Tagi + +# The tabs of sidebar +tabs: + # format: : + home: Koti + categories: Kateogoriat + tags: Tagit + archives: Arkistot + about: Minusta + +# the text displayed in the search bar & search results +search: + hint: etsi + cancel: Peruuta + no_results: Hups! Ei tuloksia. + +panel: + lastmod: Viimeksi päivitetty + trending_tags: Trendaavat tagit + toc: Sisältö + +copyright: + # Shown at the bottom of the post + license: + template: Tämä julkaisu on lisenssoitu :LICENSE_NAME julkaisijan toimesta. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Jotkut oikeudet pidätetään. + verbose: >- + Paitsi jos erikseen mainitaan on kaikki sisältö Creative Commons Attribution 4.0 International (CC BY 4.0) Lisensoitu kirjoittajan toimesta. + +meta: Käytetään :PLATFORM iä Teema :THEME + +not_found: + statement: Valitettavasti tällä URL-osoitteella ei ole saatavilla sisältöä. + +notification: + update_found: Uusi versio sisällöstä on saatavilla. + update: Päivitä + +# ----- Posts related labels ----- + +post: + written_by: Kirjoittaja + posted: Julkaistu + updated: Päivitetty + words: sanaa + pageview_measure: katselukertoja + read_time: + unit: minuuttia + prompt: lukea + relate_posts: Jatka lukemista + share: Jaa + button: + next: Uudempi + previous: Vanhempi + copy_code: + succeed: Kopiotu! + share_link: + title: Kopioi linkki + succeed: Linkki kopioitu onnistuneesti! + +# Date time format. +# See: , +df: + post: + strftime: "%b %e, %Y" + dayjs: "ll" + archives: + strftime: "%b" + dayjs: "MMM" + +# categories page +categories: + category_measure: + singular: kategoria + plural: kategoriat + post_measure: + singular: julkaisu + plural: julkaisut diff --git a/_data/locales/fr-FR.yml b/_data/locales/fr-FR.yml new file mode 100644 index 0000000..dce83c9 --- /dev/null +++ b/_data/locales/fr-FR.yml @@ -0,0 +1,77 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Post + category: Catégorie + tag: Tag + +# The tabs of sidebar +tabs: + # format: : + home: Accueil + categories: Catégories + tags: Tags + archives: Archives + about: À propos + +# the text displayed in the search bar & search results +search: + hint: recherche + cancel: Annuler + no_results: Oups ! Aucun résultat trouvé. + +panel: + lastmod: Récemment mis à jour + trending_tags: Tags tendance + toc: Contenu + +copyright: + # Shown at the bottom of the post + license: + template: Cet article est sous licence :LICENSE_NAME par l'auteur. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/deed.fr + + # Displayed in the footer + brief: Certains droits réservés. + verbose: >- + Sauf mention contraire, les articles de ce site sont publiés + sous la licence Creative Commons Attribution 4.0 International (CC BY 4.0) par l'auteur. + +meta: Propulsé par :PLATFORM avec le thème :THEME + +not_found: + statement: Désolé, nous avons égaré cette URL ou elle pointe vers quelque chose qui n'existe pas. + +notification: + update_found: Une nouvelle version du contenu est disponible. + update: Mise à jour + +# ----- Posts related labels ----- + +post: + written_by: Par + posted: Posté + updated: Mis à jour + words: mots + pageview_measure: vues + read_time: + unit: min + prompt: lire + relate_posts: Autres lectures + share: Partager + button: + next: Plus récent + previous: Plus ancien + copy_code: + succeed: Copié ! + share_link: + title: Copier le lien + succeed: Lien copié avec succès ! + +# categories page +categories: + category_measure: catégories + post_measure: posts diff --git a/_data/locales/hu-HU.yml b/_data/locales/hu-HU.yml new file mode 100644 index 0000000..53d88e9 --- /dev/null +++ b/_data/locales/hu-HU.yml @@ -0,0 +1,79 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Bejegyzés + category: Kategória + tag: Címke + +# The tabs of sidebar +tabs: + # format: : + home: Kezdőlap + categories: Kategóriák + tags: Címkék + archives: Archívum + about: Rólam + +# the text displayed in the search bar & search results +search: + hint: keresés + cancel: Mégse + no_results: Oops! Nincs találat a keresésre. + +panel: + lastmod: Legutóbb frissítve + trending_tags: Népszerű Címkék + toc: Tartalom + links: Blog linkek + +copyright: + # Shown at the bottom of the post + license: + template: A bejegyzés :LICENSE_NAME licenccel rendelkezik. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Néhány jog fenntartva. + verbose: >- + Az oldalon található tartalmak + Creative Commons Attribution 4.0 International (CC BY 4.0) licenccel rendelkeznek, + hacsak másképp nincs jelezve. + +meta: Készítve :PLATFORM motorral :THEME témával + +not_found: + statement: Sajnáljuk, az URL-t rosszul helyeztük el, vagy valami nem létezőre mutat. + +notification: + update_found: Elérhető a tartalom új verziója. + update: Frissítés + +# ----- Posts related labels ----- + +post: + written_by: Szerző + posted: Létrehozva + updated: Frissítve + words: szó + pageview_measure: látogató + read_time: + unit: perc + prompt: elolvasni + relate_posts: További olvasnivaló + share: Megosztás + button: + next: Újabb + previous: Régebbi + copy_code: + succeed: Másolva! + share_link: + title: Link másolása + succeed: Link sikeresen másolva! + +# categories page +categories: + category_measure: kategória + post_measure: bejegyzés diff --git a/_data/locales/id-ID.yml b/_data/locales/id-ID.yml new file mode 100644 index 0000000..d772ec3 --- /dev/null +++ b/_data/locales/id-ID.yml @@ -0,0 +1,77 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Postingan + category: Kategori + tag: Tagar + +# The tabs of sidebar +tabs: + # format: : + home: Beranda + categories: Kategori + tags: Tagar + archives: Arsip + about: Tentang + +# the text displayed in the search bar & search results +search: + hint: Cari + cancel: Batal + no_results: Ups! Tidak ada hasil yang ditemukan. + +panel: + lastmod: Postingan Terbaru + trending_tags: Tagar Terpopuler + toc: Konten + +copyright: + # Shown at the bottom of the post + license: + template: Postingan ini dilisensikan di bawah :LICENSE_NAME oleh penulis. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Sebagian konten dilindungi. + verbose: >- + Kecuali jika dinyatakan, Postingan blog di situs ini dilisensikan + di bawah Lisensi Creative Commons Attribution 4.0 International (CC BY 4.0) oleh penulis. + +meta: Didukung oleh :PLATFORM dengan tema :THEME + +not_found: + statement: Maaf, kami gagal menemukan URL itu atau memang mengarah ke sesuatu yang tidak ada. + +notification: + update_found: Versi konten baru tersedia. + update: Perbarui + +# ----- Posts related labels ----- + +post: + written_by: Oleh + posted: Diterbitkan + updated: Diperbarui + words: kata + pageview_measure: dilihat + read_time: + unit: menit + prompt: baca + relate_posts: Postingan Lainya + share: Bagikan + button: + next: Terbaru + previous: Terlama + copy_code: + succeed: Disalin! + share_link: + title: Salin tautan + succeed: Tautan berhasil disalin! + +# categories page +categories: + category_measure: kategori + post_measure: Postingan diff --git a/_data/locales/it-IT.yml b/_data/locales/it-IT.yml new file mode 100644 index 0000000..c8dfb44 --- /dev/null +++ b/_data/locales/it-IT.yml @@ -0,0 +1,90 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Post + category: Categoria + tag: Tag + +# The tabs of sidebar +tabs: + # format: : + home: Pagina principale + categories: Categorie + tags: Tags + archives: Archivio + about: Informazioni + +# the text displayed in the search bar & search results +search: + hint: ricerca + cancel: Cancella + no_results: Oops! La ricerca non ha fornito risultati. + +panel: + lastmod: Aggiornati recentemente + trending_tags: Tags più cliccati + toc: Contenuti + +copyright: + # Shown at the bottom of the post + license: + template: Questo post è sotto licenza :LICENSE_NAME a nome dell'autore. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Alcuni diritti riservati. + verbose: >- + Eccetto quando esplicitamente menzionato, i post di questo blog sono da ritenersi sotto + i termini di licenza Creative Commons Attribution 4.0 International (CC BY 4.0). + +meta: Servizio offerto da :PLATFORM con tema :THEME +not_found: + statement: Ci scusiamo, non è stato possibile trovare l'URL in questione. Potrebbe puntare ad una pagina non esistente. + +notification: + update_found: Nuova versione del contenuto disponibile. + update: Aggiornamento + +# ----- Posts related labels ----- + +post: + written_by: Da + posted: Postato + updated: Aggiornato + words: parole + pageview_measure: visioni + read_time: + unit: min + prompt: lettura + relate_posts: Continua a leggere + share: Condividi + button: + next: Più recenti + previous: Meno recenti + copy_code: + succeed: Copiato! + share_link: + title: Copia link + succeed: Link copiato con successo! + +# Date time format. +# See: , +df: + post: + strftime: "%b %e, %Y" + dayjs: "ll" + archives: + strftime: "%b" + dayjs: "MMM" + +# categories page +categories: + category_measure: + singular: categoria + plural: categorie + post_measure: + singular: post + plural: posts diff --git a/_data/locales/ko-KR.yml b/_data/locales/ko-KR.yml new file mode 100644 index 0000000..8297634 --- /dev/null +++ b/_data/locales/ko-KR.yml @@ -0,0 +1,84 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: 포스트 + category: 카테고리 + tag: 태그 + +# The tabs of sidebar +tabs: + # format: : + home: 홈 + categories: 카테고리 + tags: 태그 + archives: 아카이브 + about: 정보 + +# the text displayed in the search bar & search results +search: + hint: 검색 + cancel: 취소 + no_results: 검색 결과가 없습니다. + +panel: + lastmod: 최근 업데이트 + trending_tags: 인기 태그 + toc: 바로가기 + +copyright: + # Shown at the bottom of the post + license: + template: 이 기사는 저작권자의 :LICENSE_NAME 라이센스를 따릅니다. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: 일부 권리 보유 + verbose: >- + 명시되지 않는 한 이 사이트의 블로그 게시물은 작성자의 + Creative Commons Attribution 4.0 International(CC BY 4.0) 라이선스에 따라 사용이 허가되었습니다. + +meta: Powered by :PLATFORM with :THEME theme + +not_found: + statement: 해당 URL은 존재하지 않습니다. + +notification: + update_found: 새 버전의 콘텐츠를 사용할 수 있습니다. + update: 업데이트 + +# ----- Posts related labels ----- + +post: + written_by: By + posted: 게시 + updated: 업데이트 + words: 단어 + pageview_measure: 조회 + read_time: + unit: 분 + prompt: 읽는 시간 + relate_posts: 관련된 글 + share: 공유하기 + button: + next: 다음 글 + previous: 이전 글 + copy_code: + succeed: 복사되었습니다! + share_link: + title: 링크 복사하기 + succeed: 링크가 복사되었습니다! + +# Date time format. +# See: , +df: + post: + strftime: "%Y/%m/%d" + dayjs: "YYYY/MM/DD" + +# categories page +categories: + category_measure: 카테고리 + post_measure: 포스트 diff --git a/_data/locales/my-MM.yml b/_data/locales/my-MM.yml new file mode 100644 index 0000000..d5bf728 --- /dev/null +++ b/_data/locales/my-MM.yml @@ -0,0 +1,77 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: ပို့စ် + category: ကဏ္ဍ + tag: နာမ(တက်ဂ်) + +# The tabs of sidebar +tabs: + # format: : + home: အဓိကစာမျက်နှာ + categories: ကဏ္ဍများ + tags: နာမ(တက်ဂ်)များ + archives: မှတ်တမ်း​တိုက် + about: အကြောင်းအရာ + +# the text displayed in the search bar & search results +search: + hint: ရှာဖွေမည် + cancel: ဖျက်သိမ်းမည် + no_results: အိုး! ဘာမှမရှိပါ + +panel: + lastmod: မကြာသေးမီကမွမ်းမံထားသည် + trending_tags: ခေတ်စားနေသည့်တက်ဂ်များ + toc: အကြောင်းအရာများ + +copyright: + # Shown at the bottom of the post + license: + template: ဤပို့စ်သည်စာရေးသူ၏ :LICENSE_NAME လိုင်စင်ရထားသည်။ + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: မူပိုင်ခွင့်အချို့ကို လက်ဝယ်ထားသည်။ + verbose: >- + အခြားမှတ်သားထားချက်များမှလွဲ၍ ဤဆိုက်ရှိ ဘလော့ဂ်ပို့စ်များသည် စာရေးသူ၏ + Creative Commons Attribution 4.0 International (CC BY 4.0) အောက်တွင် လိုင်စင်ရထားပါသည်။ + +meta: Powered by :PLATFORM with :THEME theme + +not_found: + statement: ဝမ်းနည်းပါသည်၊ ကျွန်ုပ်တို့သည် အဆိုပါ URL ကို မှားယွင်းစွာ နေရာချထားခြင်း သို့မဟုတ် ၎င်းသည် မရှိသောအရာကို ညွှန်ပြနေပါသည်။ + +notification: + update_found: အကြောင်းအရာဗားရှင်းအသစ်ကို ရနိုင်ပါပြီ။ + update: အပ်ဒိတ် + +# ----- Posts related labels ----- + +post: + written_by: ကရေးသားခဲ့သည်။ + posted: တင်ထားခဲ့သည်။ + updated: မွမ်းမံထားခဲ့သည်။ + words: စကားလုံးများ + pageview_measure: အမြင်များ + read_time: + unit: မိနစ် + prompt: ဖတ်ပါမည် + relate_posts: နောက်ထပ်ဖတ်ရန် + share: မျှဝေရန် + button: + next: အသစ်များ + previous: အဟောင်းများ + copy_code: + succeed: ကူးယူလိုက်ပြီ။ + share_link: + title: လင့်ခ်ကို ကူးယူရန် + succeed: လင့်ခ်ကို ကူးယူလိုက်ပြီ။ + +# categories page +categories: + category_measure: ကဏ္ဍများ + post_measure: ပို့စ်များ diff --git a/_data/locales/pt-BR.yml b/_data/locales/pt-BR.yml new file mode 100644 index 0000000..7ca60a7 --- /dev/null +++ b/_data/locales/pt-BR.yml @@ -0,0 +1,77 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Post + category: Categoria + tag: Tag + +# The tabs of sidebar +tabs: + # format: : + home: Home + categories: Categorias + tags: Tags + archives: Arquivos + about: Sobre + +# the text displayed in the search bar & search results +search: + hint: Buscar + cancel: Cancelar + no_results: Oops! Nenhum resultado encontrado. + +panel: + lastmod: Atualizados recentemente + trending_tags: Trending Tags + toc: Conteúdo + +copyright: + # Shown at the bottom of the post + license: + template: Esta postagem está licenciada sob :LICENSE_NAME pelo autor. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Alguns direitos reservados. + verbose: >- + Exceto onde indicado de outra forma, as postagens do blog neste site são licenciadas sob a + Creative Commons Attribution 4.0 International (CC BY 4.0) License pelo autor. + +meta: Feito com :PLATFORM usando o tema :THEME + +not_found: + statement: Desculpe, a página não foi encontrada. + +notification: + update_found: Uma nova versão do conteúdo está disponível. + update: atualização + +# ----- Posts related labels ----- + +post: + written_by: Por + posted: Postado em + updated: Atualizado + words: palavras + pageview_measure: visualizações + read_time: + unit: min + prompt: " de leitura" + relate_posts: Leia também + share: Compartilhar + button: + next: Próximo + previous: Anterior + copy_code: + succeed: Copiado! + share_link: + title: Copie o link + succeed: Link copiado com sucesso! + +# categories page +categories: + category_measure: categorias + post_measure: posts diff --git a/_data/locales/ru-RU.yml b/_data/locales/ru-RU.yml new file mode 100644 index 0000000..868ba95 --- /dev/null +++ b/_data/locales/ru-RU.yml @@ -0,0 +1,87 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Пост + category: Категория + tag: Тег + +# The tabs of sidebar +tabs: + # format: : + home: Главная + categories: Категории + tags: Теги + archives: Архив + about: О сайте + +# the text displayed in the search bar & search results +search: + hint: поиск + cancel: Отмена + no_results: Упс! Ничего не найдено. + +panel: + lastmod: Недавно обновлено + trending_tags: Популярные теги + toc: Содержание + +copyright: + # Shown at the bottom of the post + license: + template: Авторский пост защищен лицензией :LICENSE_NAME. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Некоторые права защищены. + verbose: >- + Если не указано иное, авторские посты на этом сайте защищены лицензией Creative Commons Attribution 4.0 International (CC BY 4.0). + +meta: Использует тему :THEME для :PLATFORM + +not_found: + statement: Извините, мы перепутали URL-адрес или он указывает на что-то несуществующее. + +notification: + update_found: Доступна новая версия контента. + update: Обновить + +# ----- Posts related labels ----- + +post: + written_by: Автор + posted: Опубликовано + updated: Обновлено + words: слов + pageview_measure: просмотров + read_time: + unit: мин. + prompt: чтения + relate_posts: Похожие посты + share: Поделиться + button: + next: Следующий пост + previous: Предыдущий пост + copy_code: + succeed: Скопировано! + share_link: + title: Скопировать ссылку + succeed: Ссылка успешно скопирована! + +# Date time format. +# See: , +df: + post: + strftime: "%d.%m.%Y" + dayjs: "DD.MM.YYYY" + +# categories page +categories: + category_measure: + singular: категория + plural: категории + post_measure: + singular: пост + plural: посты diff --git a/_data/locales/sl-SI.yml b/_data/locales/sl-SI.yml new file mode 100644 index 0000000..4d9434d --- /dev/null +++ b/_data/locales/sl-SI.yml @@ -0,0 +1,91 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Objava #Post + category: Kategorija #Category + tag: Oznaka #Tag + +# The tabs of sidebar +tabs: + # format: : + home: Domov #Home + categories: Kategorije #Categories + tags: Oznake #Tags + archives: Arhiv #Archives + about: O meni #About + +# the text displayed in the search bar & search results +search: + hint: išči #search + cancel: Prekliči #Cancel + no_results: Ups! Vsebina ni bila najdena #Oops! No results found. + +panel: + lastmod: Nedavno Posodobljeno #Recently Updated + trending_tags: Priljubljene Oznake #Trending Tags + toc: Vsebina #Contents + +copyright: + # Shown at the bottom of the post + license: + template: Ta objava je licencirana pod :LICENCE_NAME s strani avtorja. #This post is licensed under :LICENSE_NAME by the author. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Nekatere pravice pridržane. #Some rights reserved. + verbose: >- + Razen kjer navedeno drugače, vse objave spletnega dnevnika so licencirane + pod Creative Commons Attribution 4.0 International (CC BY 4.0) s strani avtorja. + +meta: Uporabljena :PLATFORM tema :THEME #Using the :PLATFORM theme :THEME + +not_found: + statement: Oprostite, hiperpovezava je neustrezna ali vsebina ne obstajata. #Sorry, we've misplaced that URL or it's pointing to something that doesn't exist. + +notification: + update_found: Novejša različica vsebine je na voljo. #A new version of content is available. + update: Posodobi #Update + +# ----- Posts related labels ----- + +post: + written_by: Od #By + posted: Objavljeno #Posted + updated: Posodobljeno #Updated + words: besede #words + pageview_measure: ogledi #views + read_time: + unit: min + prompt: beri #read + relate_posts: Nadaljnje branje #Further Reading + share: Deli #Share + button: + next: Novejše #Newer + previous: Starejše #Older + copy_code: + succeed: Kopirano! #Copied! + share_link: + title: Kopiraj povezavo #Copy link + succeed: Povezava uspešno kopirana! #Link copied successfully! + +# Date time format. +# See: , +df: + post: + strftime: "%e %b, %Y" + dayjs: "ll" + archives: + strftime: "%b" + dayjs: "MMM" + +# categories page +categories: + category_measure: + singular: kategorija #category + plural: kategorije #categories + post_measure: + singular: objava #post + plural: objave #posts diff --git a/_data/locales/sv-SE.yml b/_data/locales/sv-SE.yml new file mode 100644 index 0000000..decb59c --- /dev/null +++ b/_data/locales/sv-SE.yml @@ -0,0 +1,91 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Inlägg #Post + category: Kategori #Category + tag: Tagga #Tag + +# The tabs of sidebar +tabs: + # format: : + home: Hem #Home + categories: Kategorier #Categories + tags: Taggar #Tags + archives: Arkiv #Archives + about: Om #About + +# the text displayed in the search bar & search results +search: + hint: sök + cancel: Avbryt + no_results: Hoppsan! Hittade inga sökträffar. + +panel: + lastmod: Senast uppdaterad + trending_tags: Trendande taggar + toc: Innehåll + +copyright: + # Shown at the bottom of the post + license: + template: Den här posten är publicerad under licensen :LICENSE_NAME av författaren. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Vissa rättigheter är reserverade. + verbose: >- + Om inte annat anges är blogginläggen på denna webbplats licensierade + under Creative Commons Attribution 4.0 International (CC BY 4.0) av författaren. + +meta: Byggd med :PLATFORM och temat :THEME + +not_found: + statement: Ursäkta, vi har tappat bort den här webbadressen eller så pekar den på något som inte längre finns. + +notification: + update_found: Det finns en ny version av innehållet. + update: Uppdatera sidan + +# ----- Posts related labels ----- + +post: + written_by: Av + posted: Postad + updated: Uppdaterad + words: ord + pageview_measure: visningar + read_time: + unit: min + prompt: läsning + relate_posts: Mer läsning + share: Dela + button: + next: Nyare + previous: Äldre + copy_code: + succeed: Kopierat! + share_link: + title: Kopiera länk + succeed: Länken har kopierats! + +# Date time format. +# See: , +df: + post: + strftime: "%b %e, %Y" + dayjs: "ll" + archives: + strftime: "%b" + dayjs: "MMM" + +# categories page +categories: + category_measure: + singular: kategori + plural: kategorier + post_measure: + singular: inlägg + plural: inlägg diff --git a/_data/locales/th.yml b/_data/locales/th.yml new file mode 100644 index 0000000..a3f41a0 --- /dev/null +++ b/_data/locales/th.yml @@ -0,0 +1,91 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: โพสต์ + category: หมวดหมู่ + tag: แท็ก + +# The tabs of sidebar +tabs: + # format: : + home: หน้าแรก + categories: หมวดหมู่ + tags: แท็ก + archives: คลังเก็บ + about: เกี่ยวกับ + +# the text displayed in the search bar & search results +search: + hint: ค้นหา + cancel: ยกเลิก + no_results: โอ๊ะ! ไม่พบผลลัพธ์ + +panel: + lastmod: อัปเดตล่าสุด + trending_tags: แท็กยอดนิยม + toc: เนื้อหา + +copyright: + # Shown at the bottom of the post + license: + template: โพสต์นี้อยู่ภายใต้การอนุญาต :LICENSE_NAME โดยผู้เขียน + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: สงวนลิขสิทธิ์เป็นบางส่วน + verbose: >- + เว้นแต่ว่าจะระบุเป็นอย่างอื่น โพสต์บนเว็บไซต์นี้อยู่ภายใต้ + สัญญาอนุญาตครีเอทีฟคอมมอนส์แบบ 4.0 นานาชาติ (CC BY 4.0) โดยผู้เขียน + +meta: กำลังใช้ธีมของ :PLATFORM ชื่อ :THEME + +not_found: + statement: ขออภัย เราวาง URL นั้นไว้ผิดที่ หรือมันชี้ไปยังสิ่งที่ไม่มีอยู่ + +notification: + update_found: มีเวอร์ชันใหม่ของเนื้อหา + update: อัปเดต + +# ----- Posts related labels ----- + +post: + written_by: โดย + posted: โพสต์เมื่อ + updated: อัปเดตเมื่อ + words: คำ + pageview_measure: ครั้ง + read_time: + unit: นาที + prompt: อ่าน + relate_posts: อ่านต่อ + share: แชร์ + button: + next: ใหม่กว่า + previous: เก่ากว่า + copy_code: + succeed: คัดลอกแล้ว! + share_link: + title: คัดลอกลิงก์ + succeed: คัดลอกลิงก์เรียบร้อยแล้ว! + +# Date time format. +# See: , +df: + post: + strftime: "%b %e, %Y" + dayjs: "ll" + archives: + strftime: "%b" + dayjs: "MMM" + +# categories page +categories: + category_measure: + singular: หมวดหมู่ + plural: หมวดหมู่ + post_measure: + singular: โพสต์ + plural: โพสต์ diff --git a/_data/locales/tr-TR.yml b/_data/locales/tr-TR.yml new file mode 100644 index 0000000..768f57c --- /dev/null +++ b/_data/locales/tr-TR.yml @@ -0,0 +1,77 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Gönderi + category: Kategori + tag: Etiket + +# The tabs of sidebar +tabs: + # format: : + home: Ana Sayfa + categories: Kategoriler + tags: Etiketler + archives: Arşiv + about: Hakkında + +# the text displayed in the search bar & search results +search: + hint: Ara... + cancel: İptal + no_results: Hop! Öyle bir şey bulamadım. + +panel: + lastmod: Son Güncellenenler + trending_tags: Yükselen Etiketler + toc: İçindekiler + +copyright: + # Shown at the bottom of the post + license: + template: Bu gönderi :LICENSE_NAME lisansı altındadır. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/deed.tr + + # Displayed in the footer + brief: Bazı hakları saklıdır. + verbose: >- + Aksi belirtilmediği sürece, bu sitedeki gönderiler Creative Commons Atıf 4.0 Uluslararası (CC BY 4.0) Lisansı altındadır. + Kısaca sayfa linkini vererek değiştirebilir / paylaşabilirsiniz. + +meta: :PLATFORM ve :THEME teması + +not_found: + statement: Üzgünüz, bu linki yanlış yerleştirdik veya var olmayan bir şeye işaret ediyor. + +notification: + update_found: İçeriğin yeni bir sürümü mevcut. + update: Güncelle + +# ----- Posts related labels ----- + +post: + written_by: Yazan + posted: Gönderim + updated: Güncelleme + words: sözcük + pageview_measure: görüntülenme + read_time: + unit: dakikada + prompt: okunabilir + relate_posts: Benzer Gönderiler + share: Paylaş + button: + next: İleri + previous: Geri + copy_code: + succeed: Kopyalandı. + share_link: + title: Linki kopyala + succeed: Link kopyalandı. + +# categories page +categories: + category_measure: kategori + post_measure: gönderi diff --git a/_data/locales/uk-UA.yml b/_data/locales/uk-UA.yml new file mode 100644 index 0000000..8fef52e --- /dev/null +++ b/_data/locales/uk-UA.yml @@ -0,0 +1,77 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Публікація + category: Категорія + tag: Тег + +# The tabs of sidebar +tabs: + # format: : + home: Домашня сторінка + categories: Категорії + tags: Теги + archives: Архів + about: Про сайт + +# the text displayed in the search bar & search results +search: + hint: пошук + cancel: Скасувати + no_results: Ох! Нічого не знайдено. + +panel: + lastmod: Нещодавно оновлено + trending_tags: Популярні теги + toc: Зміст + +copyright: + # Shown at the bottom of the post + license: + template: Публікація захищена ліцензією :LICENSE_NAME. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Деякі права захищено. + verbose: >- + Публікації на сайті захищено ліцензією Creative Commons Attribution 4.0 International (CC BY 4.0), + якщо інше не вказано в тексті. + +meta: Powered by :PLATFORM with :THEME theme + +not_found: + statement: Вибачте, це посилання вказує на ресурс, що не існує. + +notification: + update_found: Доступна нова версія вмісту. + update: Оновлення + +# ----- Posts related labels ----- + +post: + written_by: Автор + posted: Час публікації + updated: Оновлено + words: слів + pageview_measure: переглядів + read_time: + unit: хвилин + prompt: читання + relate_posts: Вас також може зацікавити + share: Поділитися + button: + next: Попередня публікація + previous: Наступна публікація + copy_code: + succeed: Успішно скопійовано! + share_link: + title: Скопіювати посилання + succeed: Посилання успішно скопійовано! + +# categories page +categories: + category_measure: категорії + post_measure: публікації diff --git a/_data/locales/vi-VN.yml b/_data/locales/vi-VN.yml new file mode 100644 index 0000000..6c2ceff --- /dev/null +++ b/_data/locales/vi-VN.yml @@ -0,0 +1,76 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: Bài viết + category: Danh mục + tag: Thẻ + +# The tabs of sidebar +tabs: + # format: : + home: Trang chủ + categories: Các danh mục + tags: Các thẻ + archives: Lưu trữ + about: Giới thiệu + +# the text displayed in the search bar & search results +search: + hint: tìm kiếm + cancel: Hủy + no_results: Không có kết quả tìm kiếm. + +panel: + lastmod: Mới cập nhật + trending_tags: Các thẻ thịnh hành + toc: Mục lục + +copyright: + # Shown at the bottom of the post + license: + template: Bài viết này được cấp phép bởi tác giả theo giấy phép :LICENSE_NAME. + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: Một số quyền được bảo lưu. + verbose: >- + Trừ khi có ghi chú khác, các bài viết đăng trên trang này được cấp phép bởi tác giả theo giấy phép Creative Commons Attribution 4.0 International (CC BY 4.0). + +meta: Trang web này được tạo bởi :PLATFORM với chủ đề :THEME + +not_found: + statement: Xin lỗi, chúng tôi đã đặt nhầm URL hoặc đường dẫn trỏ đến một trang nào đó không tồn tại. + +notification: + update_found: Đã có phiên bản mới của nội dung. + update: Cập nhật + +# ----- Posts related labels ----- + +post: + written_by: Viết bởi + posted: Đăng lúc + updated: Cập nhật lúc + words: từ + pageview_measure: lượt xem + read_time: + unit: phút + prompt: đọc + relate_posts: Bài viết liên quan + share: Chia sẻ + button: + next: Mới hơn + previous: Cũ hơn + copy_code: + succeed: Đã sao chép! + share_link: + title: Sao chép đường dẫn + succeed: Đã sao chép đường dẫn thành công! + +# categories page +categories: + category_measure: danh mục + post_measure: bài viết diff --git a/_data/locales/zh-CN.yml b/_data/locales/zh-CN.yml new file mode 100644 index 0000000..5c13410 --- /dev/null +++ b/_data/locales/zh-CN.yml @@ -0,0 +1,83 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: 文章 + category: 分类 + tag: 标签 + +# The tabs of sidebar +tabs: + # format: : + home: 首页 + categories: 分类 + tags: 标签 + archives: 归档 + about: 关于 + +# the text displayed in the search bar & search results +search: + hint: 搜索 + cancel: 取消 + no_results: 搜索结果为空 + +panel: + lastmod: 最近更新 + trending_tags: 热门标签 + toc: 文章内容 + +copyright: + # Shown at the bottom of the post + license: + template: 本文由作者按照 :LICENSE_NAME 进行授权 + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: 保留部分权利。 + verbose: >- + 除非另有说明,本网站上的博客文章均由作者按照知识共享署名 4.0 国际 (CC BY 4.0) 许可协议进行授权。 + +meta: 本站采用 :PLATFORM 主题 :THEME + +not_found: + statement: 抱歉,我们放错了该 URL,或者它指向了不存在的内容。 + +notification: + update_found: 发现新版本的内容。 + update: 更新 + +# ----- Posts related labels ----- + +post: + written_by: 作者 + posted: 发表于 + updated: 更新于 + words: 字 + pageview_measure: 次浏览 + read_time: + unit: 分钟 + prompt: 阅读 + relate_posts: 相关文章 + share: 分享 + button: + next: 下一篇 + previous: 上一篇 + copy_code: + succeed: 已复制! + share_link: + title: 分享链接 + succeed: 链接已复制! + +# Date time format. +# See: , +df: + post: + strftime: "%Y/%m/%d" + dayjs: "YYYY/MM/DD" + +# categories page +categories: + category_measure: 个分类 + post_measure: 篇文章 diff --git a/_data/locales/zh-TW.yml b/_data/locales/zh-TW.yml new file mode 100644 index 0000000..33a4330 --- /dev/null +++ b/_data/locales/zh-TW.yml @@ -0,0 +1,83 @@ +# The layout text of site + +# ----- Commons label ----- + +layout: + post: 文章 + category: 分類 + tag: 標籤 + +# The tabs of sidebar +tabs: + # format: : + home: 首頁 + categories: 分類 + tags: 標籤 + archives: 封存 + about: 關於 + +# the text displayed in the search bar & search results +search: + hint: 搜尋 + cancel: 取消 + no_results: 沒有搜尋結果 + +panel: + lastmod: 最近更新 + trending_tags: 熱門標籤 + toc: 文章摘要 + +copyright: + # Shown at the bottom of the post + license: + template: 本文章以 :LICENSE_NAME 授權 + name: CC BY 4.0 + link: https://creativecommons.org/licenses/by/4.0/ + + # Displayed in the footer + brief: 保留部份權利。 + verbose: >- + 除非另有說明,否則本網誌的文章均由作者按照姓名標示 4.0 國際 (CC BY 4.0) 授權條款進行授權。 + +meta: 本網站使用 :PLATFORM 產生,採用 :THEME 主題 + +not_found: + statement: 抱歉,您可能正在存取一個已被移動的 URL,或者它從未存在。 + +notification: + update_found: 發現新版本更新。 + update: 更新 + +# ----- Posts related labels ----- + +post: + written_by: 作者 + posted: 發布於 + updated: 更新於 + words: 字 + pageview_measure: 次瀏覽 + read_time: + unit: 分鐘 + prompt: 閱讀 + relate_posts: 相關文章 + share: 分享 + button: + next: 下一篇 + previous: 上一篇 + copy_code: + succeed: 已複製! + share_link: + title: 分享連結 + succeed: 已複製連結! + +# Date time format. +# See: , +df: + post: + strftime: "%Y/%m/%d" + dayjs: "YYYY/MM/DD" + +# categories page +categories: + category_measure: 個分類 + post_measure: 篇文章 diff --git a/_data/media.yml b/_data/media.yml new file mode 100644 index 0000000..9cd69b4 --- /dev/null +++ b/_data/media.yml @@ -0,0 +1,18 @@ +- extension: mp3 + mime_type: mpeg +- extension: mov + mime_type: quicktime +- extension: avi + mime_type: x-msvideo +- extension: mkv + mime_type: x-matroska +- extension: ogv + mime_type: ogg +- extension: weba + mime_type: webm +- extension: 3gp + mime_type: 3gpp +- extension: 3g2 + mime_type: 3gpp2 +- extension: mid + mime_type: midi diff --git a/_data/origin/basic.yml b/_data/origin/basic.yml new file mode 100644 index 0000000..2d52982 --- /dev/null +++ b/_data/origin/basic.yml @@ -0,0 +1,39 @@ +# fonts + +webfonts: /assets/lib/fonts/main.css + +# Libraries + +toc: + css: /assets/lib/tocbot/tocbot.min.css + js: /assets/lib/tocbot/tocbot.min.js + +fontawesome: + css: /assets/lib/fontawesome-free/css/all.min.css + +search: + js: /assets/lib/simple-jekyll-search/simple-jekyll-search.min.js + +mermaid: + js: /assets/lib/mermaid/mermaid.min.js + +dayjs: + js: + common: /assets/lib/dayjs/dayjs.min.js + locale: /assets/lib/dayjs/locale/en.js + relativeTime: /assets/lib/dayjs/plugin/relativeTime.js + localizedFormat: /assets/lib/dayjs/plugin/localizedFormat.js + +glightbox: + css: /assets/lib/glightbox/glightbox.min.css + js: /assets/lib/glightbox/glightbox.min.js + +lazy-polyfill: + css: /assets/lib/loading-attribute-polyfill/loading-attribute-polyfill.min.css + js: /assets/lib/loading-attribute-polyfill/loading-attribute-polyfill.umd.min.js + +clipboard: + js: /assets/lib/clipboard/clipboard.min.js + +mathjax: + js: /assets/lib/mathjax/tex-chtml.js diff --git a/_data/origin/cors.yml b/_data/origin/cors.yml new file mode 100644 index 0000000..afdb3d9 --- /dev/null +++ b/_data/origin/cors.yml @@ -0,0 +1,54 @@ +# Resource Hints +resource_hints: + - url: https://fonts.googleapis.com + links: + - rel: preconnect + - rel: dns-prefetch + - url: https://fonts.gstatic.com + links: + - rel: preconnect + opts: [crossorigin] + - rel: dns-prefetch + - url: https://cdn.jsdelivr.net + links: + - rel: preconnect + - rel: dns-prefetch + +# Web Fonts +webfonts: https://fonts.googleapis.com/css2?family=Lato:wght@300;400&family=Source+Sans+Pro:wght@400;600;700;900&display=swap + +# Libraries + +toc: + css: https://cdn.jsdelivr.net/npm/tocbot@4.29.0/dist/tocbot.min.css + js: https://cdn.jsdelivr.net/npm/tocbot@4.29.0/dist/tocbot.min.js + +fontawesome: + css: https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.6.0/css/all.min.css + +search: + js: https://cdn.jsdelivr.net/npm/simple-jekyll-search@1.10.0/dest/simple-jekyll-search.min.js + +mermaid: + js: https://cdn.jsdelivr.net/npm/mermaid@11.0.2/dist/mermaid.min.js + +dayjs: + js: + common: https://cdn.jsdelivr.net/npm/dayjs@1.11.13/dayjs.min.js + locale: https://cdn.jsdelivr.net/npm/dayjs@1.11.13/locale/:LOCALE.js + relativeTime: https://cdn.jsdelivr.net/npm/dayjs@1.11.13/plugin/relativeTime.js + localizedFormat: https://cdn.jsdelivr.net/npm/dayjs@1.11.13/plugin/localizedFormat.js + +glightbox: + css: https://cdn.jsdelivr.net/npm/glightbox@3.3.0/dist/css/glightbox.min.css + js: https://cdn.jsdelivr.net/npm/glightbox@3.3.0/dist/js/glightbox.min.js + +lazy-polyfill: + css: https://cdn.jsdelivr.net/npm/loading-attribute-polyfill@2.1.1/dist/loading-attribute-polyfill.min.css + js: https://cdn.jsdelivr.net/npm/loading-attribute-polyfill@2.1.1/dist/loading-attribute-polyfill.umd.min.js + +clipboard: + js: https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js + +mathjax: + js: https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-chtml.js diff --git a/_data/share.yml b/_data/share.yml new file mode 100644 index 0000000..7bebf59 --- /dev/null +++ b/_data/share.yml @@ -0,0 +1,50 @@ +# Sharing options at the bottom of the post. +# Icons from + +platforms: + - type: Twitter + icon: "fa-brands fa-square-x-twitter" + link: "https://twitter.com/intent/tweet?text=TITLE&url=URL" + + # - type: Facebook + # icon: "fab fa-facebook-square" + # link: "https://www.facebook.com/sharer/sharer.php?title=TITLE&u=URL" + + - type: Telegram + icon: "fab fa-telegram" + link: "https://t.me/share/url?url=URL&text=TITLE" + + # Uncomment below if you need to. + # + - type: Linkedin + icon: "fab fa-linkedin" + link: "https://www.linkedin.com/sharing/share-offsite/?url=URL" + # + # - type: Weibo + # icon: "fab fa-weibo" + # link: "https://service.weibo.com/share/share.php?title=TITLE&url=URL" + # + - type: Mastodon + icon: "fa-brands fa-mastodon" + # # See: https://github.com/justinribeiro/share-to-mastodon#properties + # instances: + # - label: mastodon.social + # link: "https://mastodon.social/" + # - label: mastodon.online + # link: "https://mastodon.online/" + # - label: fosstodon.org + # link: "https://fosstodon.org/" + # - label: photog.social + # link: "https://photog.social/" + # + # - type: Bluesky + # icon: "fa-brands fa-bluesky" + # link: "https://bsky.app/intent/compose?text=TITLE%20URL" + # + # - type: Reddit + # icon: "fa-brands fa-square-reddit" + # link: "https://www.reddit.com/submit?url=URL&title=TITLE" + # + # - type: Threads + # icon: "fa-brands fa-square-threads" + # link: "https://www.threads.net/intent/post?text=TITLE%20URL" diff --git a/_includes/analytics/cloudflare.html b/_includes/analytics/cloudflare.html new file mode 100644 index 0000000..1eeb1a9 --- /dev/null +++ b/_includes/analytics/cloudflare.html @@ -0,0 +1,7 @@ + + + diff --git a/_includes/analytics/fathom.html b/_includes/analytics/fathom.html new file mode 100644 index 0000000..4b603d3 --- /dev/null +++ b/_includes/analytics/fathom.html @@ -0,0 +1,7 @@ + + + diff --git a/_includes/analytics/goatcounter.html b/_includes/analytics/goatcounter.html new file mode 100644 index 0000000..3867fdb --- /dev/null +++ b/_includes/analytics/goatcounter.html @@ -0,0 +1,6 @@ + + diff --git a/_includes/analytics/google.html b/_includes/analytics/google.html new file mode 100644 index 0000000..d0aac65 --- /dev/null +++ b/_includes/analytics/google.html @@ -0,0 +1,13 @@ + + + diff --git a/_includes/analytics/matomo.html b/_includes/analytics/matomo.html new file mode 100644 index 0000000..72b2c46 --- /dev/null +++ b/_includes/analytics/matomo.html @@ -0,0 +1,14 @@ + + + diff --git a/_includes/analytics/umami.html b/_includes/analytics/umami.html new file mode 100644 index 0000000..bfcb1d0 --- /dev/null +++ b/_includes/analytics/umami.html @@ -0,0 +1,6 @@ + + diff --git a/_includes/comments.html b/_includes/comments.html new file mode 100644 index 0000000..fef135f --- /dev/null +++ b/_includes/comments.html @@ -0,0 +1,5 @@ + +{% if page.comments and site.comments.provider %} + {% capture path %}comments/{{ site.comments.provider }}.html{% endcapture %} + {% include {{ path }} %} +{% endif %} diff --git a/_includes/comments/disqus.html b/_includes/comments/disqus.html new file mode 100644 index 0000000..2b889a4 --- /dev/null +++ b/_includes/comments/disqus.html @@ -0,0 +1,50 @@ + + +
+

Comments powered by Disqus.

+
+ + diff --git a/_includes/comments/giscus.html b/_includes/comments/giscus.html new file mode 100644 index 0000000..f9becfe --- /dev/null +++ b/_includes/comments/giscus.html @@ -0,0 +1,71 @@ + + diff --git a/_includes/comments/utterances.html b/_includes/comments/utterances.html new file mode 100644 index 0000000..5dd78ed --- /dev/null +++ b/_includes/comments/utterances.html @@ -0,0 +1,49 @@ + + + + diff --git a/_includes/datetime.html b/_includes/datetime.html new file mode 100644 index 0000000..9f954b6 --- /dev/null +++ b/_includes/datetime.html @@ -0,0 +1,20 @@ + + +{% assign df_strftime = site.data.locales[include.lang].df.post.strftime | default: '%d/%m/%Y' %} +{% assign df_dayjs = site.data.locales[include.lang].df.post.dayjs | default: 'DD/MM/YYYY' %} + + diff --git a/_includes/embed/audio.html b/_includes/embed/audio.html new file mode 100644 index 0000000..cf928a7 --- /dev/null +++ b/_includes/embed/audio.html @@ -0,0 +1,35 @@ +{% assign src = include.src | strip %} +{% assign title = include.title | strip %} +{% assign types = include.types | default: '' | strip | split: '|' %} + +{% unless src contains '://' %} + {%- capture src -%} + {% include media-url.html src=src subpath=page.media_subpath %} + {%- endcapture -%} +{% endunless %} + +

+ + {% if title %} + {{ title }} + {% endif %} +

diff --git a/_includes/embed/bilibili.html b/_includes/embed/bilibili.html new file mode 100644 index 0000000..0aa5552 --- /dev/null +++ b/_includes/embed/bilibili.html @@ -0,0 +1,9 @@ + diff --git a/_includes/embed/twitch.html b/_includes/embed/twitch.html new file mode 100644 index 0000000..ed5ec83 --- /dev/null +++ b/_includes/embed/twitch.html @@ -0,0 +1,8 @@ + diff --git a/_includes/embed/video.html b/_includes/embed/video.html new file mode 100644 index 0000000..9b6918f --- /dev/null +++ b/_includes/embed/video.html @@ -0,0 +1,59 @@ +{% assign video_url = include.src %} +{% assign title = include.title %} +{% assign poster_url = include.poster %} +{% assign types = include.types | default: '' | strip | split: '|' %} + +{% unless video_url contains '://' %} + {%- capture video_url -%} + {% include media-url.html src=video_url subpath=page.media_subpath %} + {%- endcapture -%} +{% endunless %} + +{% if poster_url %} + {% unless poster_url contains '://' %} + {%- capture poster_url -%} + {% include media-url.html src=poster_url subpath=page.media_subpath %} + {%- endcapture -%} + {% endunless %} + {% assign poster = 'poster="' | append: poster_url | append: '"' %} +{% endif %} + +{% assign attributes = 'controls' %} + +{% if include.autoplay %} + {% assign attributes = attributes | append: ' ' | append: 'autoplay' %} +{% endif %} + +{% if include.loop %} + {% assign attributes = attributes | append: ' ' | append: 'loop' %} +{% endif %} + +{% if include.muted %} + {% assign attributes = attributes | append: ' ' | append: 'muted' %} +{% endif %} + +

+ + {% if title %} + {{ title }} + {% endif %} +

diff --git a/_includes/embed/youtube.html b/_includes/embed/youtube.html new file mode 100644 index 0000000..8f08002 --- /dev/null +++ b/_includes/embed/youtube.html @@ -0,0 +1,9 @@ + diff --git a/_includes/favicons.html b/_includes/favicons.html new file mode 100644 index 0000000..957c933 --- /dev/null +++ b/_includes/favicons.html @@ -0,0 +1,19 @@ + + +{% capture favicon_path %}{{ '/assets/img/favicons' | relative_url }}{% endcapture %} + + + + +{% if site.pwa.enabled %} + +{% endif %} + + + + + + diff --git a/_includes/footer.html b/_includes/footer.html new file mode 100644 index 0000000..1ba9b63 --- /dev/null +++ b/_includes/footer.html @@ -0,0 +1,49 @@ + + +
+

+ {{- '©' }} + + + {% if site.social.links %} + {{ site.social.name }}. + {% else %} + {{ site.social.name }}. + {% endif %} + + {% if site.data.locales[include.lang].copyright.brief %} + + {{- site.data.locales[include.lang].copyright.brief -}} + + {% endif %} +

+ +

+ {%- capture _platform -%} + Jekyll + {%- endcapture -%} + + {%- capture _theme -%} + Chirpy + {%- endcapture -%} + + {{ site.data.locales[include.lang].meta | replace: ':PLATFORM', _platform | replace: ':THEME', _theme }} +

+
diff --git a/_includes/head.html b/_includes/head.html new file mode 100644 index 0000000..eca1df7 --- /dev/null +++ b/_includes/head.html @@ -0,0 +1,112 @@ + + + + + + + + + {%- capture seo_tags -%} + {% seo title=false %} + {%- endcapture -%} + + + + {% if page.image %} + {% assign src = page.image.path | default: page.image %} + + {% unless src contains '://' %} + {%- capture img_url -%} + {% include media-url.html src=src subpath=page.media_subpath absolute=true %} + {%- endcapture -%} + + {%- capture old_url -%}{{ src | absolute_url }}{%- endcapture -%} + {%- capture new_url -%}{{ img_url }}{%- endcapture -%} + + {% assign seo_tags = seo_tags | replace: old_url, new_url %} + {% endunless %} + + {% elsif site.social_preview_image %} + {%- capture img_url -%} + {% include media-url.html src=site.social_preview_image absolute=true %} + {%- endcapture -%} + + {%- capture og_image -%} + + {%- endcapture -%} + + {%- capture twitter_image -%} + + + {%- endcapture -%} + + {% assign old_meta_clip = '' %} + {% assign new_meta_clip = og_image | append: twitter_image %} + {% assign seo_tags = seo_tags | replace: old_meta_clip, new_meta_clip %} + {% endif %} + + {{ seo_tags }} + + + {%- unless page.layout == 'home' -%} + {{ page.title | append: ' | ' }} + {%- endunless -%} + {{ site.title }} + + + {% include_cached favicons.html %} + + + {% unless site.assets.self_host.enabled %} + {% for hint in site.data.origin.cors.resource_hints %} + {% for link in hint.links %} + + {% endfor %} + {% endfor %} + {% endunless %} + + + + + + {% unless jekyll.environment == 'production' %} + + {% endunless %} + + + + + + + + + + + + + {% if site.toc and page.toc %} + + {% endif %} + + {% if page.layout == 'post' or page.layout == 'page' or page.layout == 'home' %} + + {% endif %} + + {% if page.layout == 'page' or page.layout == 'post' %} + + + {% endif %} + + + + {% unless site.theme_mode %} + {% include mode-toggle.html %} + {% endunless %} + + {% include metadata-hook.html %} + + diff --git a/_includes/js-selector.html b/_includes/js-selector.html new file mode 100644 index 0000000..4d77d06 --- /dev/null +++ b/_includes/js-selector.html @@ -0,0 +1,109 @@ + + + + +{% assign urls = site.data.origin[type].search.js %} + + + +{% if page.layout == 'post' or page.layout == 'page' or page.layout == 'home' %} + {% assign urls = urls | append: ',' | append: site.data.origin[type]['lazy-polyfill'].js %} + + {% unless page.layout == 'home' %} + + {% assign urls = urls + | append: ',' + | append: site.data.origin[type].glightbox.js + | append: ',' + | append: site.data.origin[type].clipboard.js + %} + {% endunless %} +{% endif %} + +{% if page.layout == 'home' + or page.layout == 'post' + or page.layout == 'archives' + or page.layout == 'category' + or page.layout == 'tag' +%} + {% assign locale = include.lang | split: '-' | first %} + + {% assign urls = urls + | append: ',' + | append: site.data.origin[type].dayjs.js.common + | append: ',' + | append: site.data.origin[type].dayjs.js.locale + | replace: ':LOCALE', locale + | append: ',' + | append: site.data.origin[type].dayjs.js.relativeTime + | append: ',' + | append: site.data.origin[type].dayjs.js.localizedFormat + %} +{% endif %} + +{% if page.content contains ' + +{% if page.math %} + + + + +{% endif %} + + +{% if page.layout == 'post' %} + {% assign provider = site.pageviews.provider %} + + {% if provider and provider != empty %} + {% case provider %} + {% when 'goatcounter' %} + {% if site.analytics[provider].id != empty and site.analytics[provider].id %} + {% include pageviews/{{ provider }}.html %} + {% endif %} + {% endcase %} + {% endif %} +{% endif %} + +{% if page.mermaid %} + {% include mermaid.html %} +{% endif %} + +{% if jekyll.environment == 'production' %} + + {% if site.pwa.enabled %} + + {% endif %} + + + {% for analytics in site.analytics %} + {% capture str %}{{ analytics }}{% endcapture %} + {% assign type = str | split: '{' | first %} + {% if site.analytics[type].id and site.analytics[type].id != empty %} + {% include analytics/{{ type }}.html %} + {% endif %} + {% endfor %} +{% endif %} diff --git a/_includes/jsdelivr-combine.html b/_includes/jsdelivr-combine.html new file mode 100644 index 0000000..cffa699 --- /dev/null +++ b/_includes/jsdelivr-combine.html @@ -0,0 +1,26 @@ +{% assign urls = include.urls | split: ',' %} + +{% assign combined_urls = nil %} + +{% assign domain = 'https://cdn.jsdelivr.net/' %} + +{% for url in urls %} + {% if url contains domain %} + {% assign url_snippet = url | slice: domain.size, url.size %} + + {% if combined_urls %} + {% assign combined_urls = combined_urls | append: ',' | append: url_snippet %} + {% else %} + {% assign combined_urls = domain | append: 'combine/' | append: url_snippet %} + {% endif %} + + {% elsif url contains '//' %} + + {% else %} + + {% endif %} +{% endfor %} + +{% if combined_urls %} + +{% endif %} diff --git a/_includes/lang.html b/_includes/lang.html new file mode 100644 index 0000000..34b50df --- /dev/null +++ b/_includes/lang.html @@ -0,0 +1,10 @@ +{% comment %} + Detect appearance language and return it through variable "lang" +{% endcomment %} +{% if site.data.locales[page.lang] %} + {% assign lang = page.lang %} +{% elsif site.data.locales[site.lang] %} + {% assign lang = site.lang %} +{% else %} + {% assign lang = 'en' %} +{% endif %} diff --git a/_includes/language-alias.html b/_includes/language-alias.html new file mode 100644 index 0000000..abfa7ba --- /dev/null +++ b/_includes/language-alias.html @@ -0,0 +1,70 @@ +{% comment %} + + Convert the alias of the syntax language to the official name + + See: + +{% endcomment %} + +{% assign _lang = include.language | default: '' %} + +{% case _lang %} + {% when 'actionscript', 'as', 'as3' %} + {{ 'ActionScript' }} + {% when 'applescript' %} + {{ 'AppleScript' }} + {% when 'brightscript', 'bs', 'brs' %} + {{ 'BrightScript' }} + {% when 'cfscript', 'cfc' %} + {{ 'CFScript' }} + {% when 'coffeescript', 'coffee', 'coffee-script' %} + {{ 'CoffeeScript' }} + {% when 'cs', 'csharp' %} + {{ 'C#' }} + {% when 'erl' %} + {{ 'Erlang' }} + {% when 'graphql' %} + {{ 'GraphQL' }} + {% when 'haskell', 'hs' %} + {{ 'Haskell' }} + {% when 'javascript', 'js' %} + {{ 'JavaScript' }} + {% when 'make', 'mf', 'gnumake', 'bsdmake' %} + {{ 'Makefile' }} + {% when 'md', 'mkd' %} + {{ 'Markdown' }} + {% when 'm' %} + {{ 'Matlab' }} + {% when 'objective_c', 'objc', 'obj-c', 'obj_c', 'objectivec' %} + {{ 'Objective-C' }} + {% when 'perl', 'pl' %} + {{ 'Perl' }} + {% when 'php','php3','php4','php5' %} + {{ 'PHP' }} + {% when 'py' %} + {{ 'Python' }} + {% when 'rb' %} + {{ 'Ruby' }} + {% when 'rs','no_run','ignore','should_panic' %} + {{ 'Rust' }} + {% when 'bash', 'zsh', 'ksh', 'sh' %} + {{ 'Shell' }} + {% when 'st', 'squeak' %} + {{ 'Smalltalk' }} + {% when 'tex'%} + {{ 'TeX' }} + {% when 'latex' %} + {{ 'LaTex' }} + {% when 'ts', 'typescript' %} + {{ 'TypeScript' }} + {% when 'vb', 'visualbasic' %} + {{ 'Visual Basic' }} + {% when 'vue', 'vuejs' %} + {{ 'Vue.js' }} + {% when 'yml' %} + {{ 'YAML' }} + {% when 'css', 'html', 'scss', 'ssh', 'toml', 'xml', 'yaml', 'json' %} + {{ _lang | upcase }} + {% else %} + {{ _lang | capitalize }} +{% endcase %} diff --git a/_includes/media-url.html b/_includes/media-url.html new file mode 100644 index 0000000..ea41075 --- /dev/null +++ b/_includes/media-url.html @@ -0,0 +1,37 @@ +{%- comment -%} + Generate media resource final URL based on `site.cdn`, `page.media_subpath` + + Arguments: + src - required, basic media resources path + subpath - optional, relative path of media resources + absolute - optional, boolean, if true, generate absolute URL + + Return: + media resources URL +{%- endcomment -%} + +{% assign url = include.src %} + +{%- if url -%} + {% unless url contains ':' %} + {%- comment -%} Add media resources subpath prefix {%- endcomment -%} + {% assign url = include.subpath | default: '' | append: '/' | append: url %} + + {%- comment -%} Prepend CND URL {%- endcomment -%} + {% if site.cdn %} + {% assign url = site.cdn | append: '/' | append: url %} + {% endif %} + + {% assign url = url | replace: '///', '/' | replace: '//', '/' | replace: ':/', '://' %} + + {% unless url contains '://' %} + {% if include.absolute %} + {% assign url = site.url | append: site.baseurl | append: url %} + {% else %} + {% assign url = site.baseurl | append: url %} + {% endif %} + {% endunless %} + {% endunless %} +{%- endif -%} + +{{- url -}} diff --git a/_includes/mermaid.html b/_includes/mermaid.html new file mode 100644 index 0000000..a3a83ed --- /dev/null +++ b/_includes/mermaid.html @@ -0,0 +1,62 @@ + + diff --git a/_includes/metadata-hook.html b/_includes/metadata-hook.html new file mode 100644 index 0000000..fd7e9bd --- /dev/null +++ b/_includes/metadata-hook.html @@ -0,0 +1 @@ + diff --git a/_includes/mode-toggle.html b/_includes/mode-toggle.html new file mode 100644 index 0000000..113ec37 --- /dev/null +++ b/_includes/mode-toggle.html @@ -0,0 +1,116 @@ + + + diff --git a/_includes/no-linenos.html b/_includes/no-linenos.html new file mode 100644 index 0000000..8500693 --- /dev/null +++ b/_includes/no-linenos.html @@ -0,0 +1,10 @@ +{% comment %} + Remove the line number of the code snippet. +{% endcomment %} + +{% assign content = include.content %} + +{% if content contains '
' %}
+  {% assign content = content | replace: '
', '' %}
+{% endif %}
diff --git a/_includes/notification.html b/_includes/notification.html
new file mode 100644
index 0000000..80049b0
--- /dev/null
+++ b/_includes/notification.html
@@ -0,0 +1,24 @@
+
diff --git a/_includes/origin-type.html b/_includes/origin-type.html
new file mode 100644
index 0000000..7f72012
--- /dev/null
+++ b/_includes/origin-type.html
@@ -0,0 +1,13 @@
+{% comment %} Site static assets origin type {% endcomment %}
+
+{% assign type = 'cors' %}
+
+{% if site.assets.self_host.enabled %}
+  {% if site.assets.self_host.env %}
+    {% if site.assets.self_host.env == jekyll.environment %}
+      {% assign type = 'basic' %}
+    {% endif %}
+  {% else %}
+    {% assign type = 'basic' %}
+  {% endif %}
+{% endif %}
diff --git a/_includes/pageviews/goatcounter.html b/_includes/pageviews/goatcounter.html
new file mode 100644
index 0000000..e62fd69
--- /dev/null
+++ b/_includes/pageviews/goatcounter.html
@@ -0,0 +1,19 @@
+
+
diff --git a/_includes/post-description.html b/_includes/post-description.html
new file mode 100644
index 0000000..6c40036
--- /dev/null
+++ b/_includes/post-description.html
@@ -0,0 +1,16 @@
+{%- comment -%}
+  Get post description or generate it from the post content.
+{%- endcomment -%}
+
+{%- assign max_length = include.max_length | default: 200 -%}
+
+{%- capture description -%}
+{%- if post.description -%}
+  {{- post.description -}}
+{%- else -%}
+  {%- include no-linenos.html content=post.content -%}
+  {{- content | markdownify | strip_html -}}
+{%- endif -%}
+{%- endcapture -%}
+
+{{- description | strip | truncate: max_length | escape -}}
diff --git a/_includes/post-nav.html b/_includes/post-nav.html
new file mode 100644
index 0000000..736bec3
--- /dev/null
+++ b/_includes/post-nav.html
@@ -0,0 +1,34 @@
+
+
+
diff --git a/_includes/post-paginator.html b/_includes/post-paginator.html
new file mode 100644
index 0000000..c74e978
--- /dev/null
+++ b/_includes/post-paginator.html
@@ -0,0 +1,91 @@
+
+
+
+
diff --git a/_includes/post-sharing.html b/_includes/post-sharing.html
new file mode 100644
index 0000000..d894199
--- /dev/null
+++ b/_includes/post-sharing.html
@@ -0,0 +1,52 @@
+
+
+
diff --git a/_includes/read-time.html b/_includes/read-time.html
new file mode 100644
index 0000000..9952410
--- /dev/null
+++ b/_includes/read-time.html
@@ -0,0 +1,37 @@
+
+
+{% assign words = include.content | strip_html | number_of_words: 'auto' %}
+
+
+
+{% assign wpm = 180 %}
+{% assign min_time = 1 %}
+
+{% assign read_time = words | divided_by: wpm %}
+
+{% unless read_time > 0 %}
+  {% assign read_time = min_time %}
+{% endunless %}
+
+{% capture read_prompt %}
+  {{- site.data.locales[include.lang].post.read_time.prompt -}}
+{% endcapture %}
+
+
+
+  
+    {{- read_time -}}
+    {{ ' ' }}
+    {{- site.data.locales[include.lang].post.read_time.unit -}}
+  
+  {%- if include.prompt -%}
+    {%- assign _prompt_words = read_prompt | number_of_words: 'auto' -%}
+    {%- unless _prompt_words > 1 -%}{{ ' ' }}{%- endunless -%}
+    {{ read_prompt }}
+  {%- endif -%}
+
diff --git a/_includes/refactor-content.html b/_includes/refactor-content.html
new file mode 100644
index 0000000..8d298cd
--- /dev/null
+++ b/_includes/refactor-content.html
@@ -0,0 +1,255 @@
+
+
+{% assign _content = include.content %}
+
+
+
+{% if _content contains '', ''
+    | replace: '
', '' + | replace: '
', '
' + %} +{% endif %} + + + +{% if _content contains '
' %}
+  {% assign _content = _content
+    | replace: '
', '' + %} +{% endif %} + + + +{% if _content contains '', + '' + | replace: '', + '' + %} +{% endif %} + + + +{% assign IMG_TAG = '' | first %} + {% assign _right = _img_snippet | remove: _left %} + + {% unless _left contains 'src=' %} + {% continue %} + {% endunless %} + + {% assign _left = _left | remove: ' /' | replace: ' w=', ' width=' | replace: ' h=', ' height=' %} + {% assign _attrs = _left | split: '" ' %} + + {% assign _src = null %} + {% assign _lqip = null %} + {% assign _class = null %} + + {% for _attr in _attrs %} + {% unless _attr contains '=' %} + {% continue %} + {% endunless %} + + {% assign _pair = _attr | split: '="' %} + {% capture _key %}{{ _pair | first }}{% endcapture %} + {% capture _value %}{{ _pair | last | remove: '"' }}{% endcapture %} + + {% case _key %} + {% when 'src' %} + {% assign _src = _value %} + {% when 'lqip' %} + {% assign _lqip = _value %} + {% when 'class' %} + {% assign _class = _value %} + {% endcase %} + {% endfor %} + + + {% if _class %} + {% capture _old_class %}class="{{ _class }}"{% endcapture %} + {% assign _left = _left | remove: _old_class %} + {% endif %} + + {% assign _final_src = null %} + {% assign _lazyload = true %} + + {%- capture _img_url -%} + {% include media-url.html src=_src subpath=page.media_subpath %} + {%- endcapture -%} + + {% assign _path_prefix = _img_url | remove: _src %} + + {% unless _src contains '//' %} + {% assign _final_src = _path_prefix | append: _src %} + {% assign _src_alt = 'src="' | append: _path_prefix %} + {% assign _left = _left | replace: 'src="', _src_alt %} + {% endunless %} + + {% if _lqip %} + {% assign _lazyload = false %} + {% assign _class = _class | append: ' blur' %} + + {% unless _lqip contains 'data:' %} + {% assign _lqip_alt = 'lqip="' | append: _path_prefix %} + {% assign _left = _left | replace: 'lqip="', _lqip_alt %} + {% endunless %} + + + {% assign _left = _left | replace: 'src=', 'data-src=' | replace: ' lqip=', ' data-lqip="true" src=' %} + + {% else %} + {% assign _class = _class | append: ' shimmer' %} + {% endif %} + + + {% if _lazyload %} + {% assign _left = _left | append: ' loading="lazy"' %} + {% endif %} + + {% if page.layout == 'home' %} + + {% assign _wrapper_start = '
' %} + + {% assign _img_content = _img_content | append: _wrapper_start %} + {% assign _right = _right | prepend: '>` is wrapped by `` --> + {% assign _parent = _right | slice: 1, 4 %} + + {% if _parent == '' %} + + {% assign _size = _img_content | size | minus: 1 %} + {% capture _class %} + class="img-link{% unless _lqip %} shimmer{% endunless %}" + {% endcapture %} + {% assign _img_content = _img_content | slice: 0, _size | append: _class | append: '>' %} + + {% else %} + + {% assign _wrapper_start = _final_src + | default: _src + | prepend: '' + %} + + {% assign _img_content = _img_content | append: _wrapper_start %} + {% assign _right = '> + {% assign _img_content = _img_content | append: IMG_TAG | append: _left | append: _right %} + {% endfor %} + + {% if _img_content %} + {% assign _content = _img_content %} + {% endif %} +{% endif %} + + + +{% if _content contains '
' %} + {% assign _code_spippets = _content | split: '
' %} + {% assign _new_content = '' %} + + {% for _snippet in _code_spippets %} + {% if forloop.last %} + {% assign _new_content = _new_content | append: _snippet %} + + {% else %} + {% assign _left = _snippet | split: '><' | last %} + + {% if _left contains 'file="' %} + {% assign _label_text = _left | split: 'file="' | last | split: '"' | first %} + {% assign _label_icon = 'far fa-file-code fa-fw' %} + {% else %} + {% assign _lang = _left | split: 'language-' | last | split: ' ' | first %} + {% capture _label_text %}{% include language-alias.html language=_lang %}{% endcapture %} + {% assign _label_icon = 'fas fa-code fa-fw small' %} + {% endif %} + + {% capture _label %} + + {% endcapture %} + + {% assign _new_content = _new_content + | append: _snippet + | append: '
' + | append: _label + | append: '
' + | append: '
' + %} + {% endif %} + {% endfor %} + + {% assign _content = _new_content %} +{% endif %} + + + +{% assign heading_levels = '2,3,4,5' | split: ',' %} +{% assign _heading_content = _content %} + +{% for level in heading_levels %} + {% assign mark_start = '' + %} + + {% assign left = snippet | split: mark_end | first %} + {% assign right = snippet | slice: left.size, snippet.size %} + {% assign left = left | replace_first: '">', '">' | append: '' %} + + {% assign _new_content = _new_content | append: mark_start | append: left | append: anchor | append: right %} + {% endfor %} + + {% assign _heading_content = _new_content %} + {% endif %} +{% endfor %} + +{% assign _content = _heading_content %} + + +{{ _content }} diff --git a/_includes/related-posts.html b/_includes/related-posts.html new file mode 100644 index 0000000..37a295b --- /dev/null +++ b/_includes/related-posts.html @@ -0,0 +1,94 @@ + + + +{% assign TOTAL_SIZE = 3 %} + + +{% assign TAG_SCORE = 1 %} + + +{% assign CATEGORY_SCORE = 0.5 %} + +{% assign SEPARATOR = ':' %} + +{% assign match_posts = '' | split: '' %} + +{% for category in page.categories %} + {% assign match_posts = match_posts | push: site.categories[category] | uniq %} +{% endfor %} + +{% for tag in page.tags %} + {% assign match_posts = match_posts | push: site.tags[tag] | uniq %} +{% endfor %} + +{% assign match_posts = match_posts | reverse %} +{% assign last_index = match_posts.size | minus: 1 %} +{% assign score_list = '' | split: '' %} + +{% for i in (0..last_index) %} + {% assign post = match_posts[i] %} + + {% if post.url == page.url %} + {% continue %} + {% endif %} + + {% assign score = 0 %} + + {% for tag in post.tags %} + {% if page.tags contains tag %} + {% assign score = score | plus: TAG_SCORE %} + {% endif %} + {% endfor %} + + {% for category in post.categories %} + {% if page.categories contains category %} + {% assign score = score | plus: CATEGORY_SCORE %} + {% endif %} + {% endfor %} + + {% if score > 0 %} + {% capture score_item %}{{ score }}{{ SEPARATOR }}{{ i }}{% endcapture %} + {% assign score_list = score_list | push: score_item %} + {% endif %} +{% endfor %} + +{% assign index_list = '' | split: '' %} + +{% if score_list.size > 0 %} + {% assign score_list = score_list | sort | reverse %} + {% for entry in score_list limit: TOTAL_SIZE %} + {% assign index = entry | split: SEPARATOR | last %} + {% assign index_list = index_list | push: index %} + {% endfor %} +{% endif %} + +{% assign relate_posts = '' | split: '' %} + +{% for index in index_list %} + {% assign i = index | to_integer %} + {% assign relate_posts = relate_posts | push: match_posts[i] %} +{% endfor %} + +{% if relate_posts.size > 0 %} + + +{% endif %} diff --git a/_includes/search-loader.html b/_includes/search-loader.html new file mode 100644 index 0000000..2582580 --- /dev/null +++ b/_includes/search-loader.html @@ -0,0 +1,47 @@ + + +{% capture result_elem %} +
+
+

{title}

+ +
+

{snippet}

+
+{% endcapture %} + +{% capture not_found %}

{{ site.data.locales[include.lang].search.no_results }}

{% endcapture %} + + diff --git a/_includes/search-results.html b/_includes/search-results.html new file mode 100644 index 0000000..00a3182 --- /dev/null +++ b/_includes/search-results.html @@ -0,0 +1,10 @@ + + +
+
+
+ {% include_cached trending-tags.html %} +
+
+
+
diff --git a/_includes/sidebar.html b/_includes/sidebar.html new file mode 100644 index 0000000..4f0bb8c --- /dev/null +++ b/_includes/sidebar.html @@ -0,0 +1,99 @@ + + + + diff --git a/_includes/toc.html b/_includes/toc.html new file mode 100644 index 0000000..15eb934 --- /dev/null +++ b/_includes/toc.html @@ -0,0 +1,13 @@ +{% assign enable_toc = false %} +{% if site.toc and page.toc %} + {% if page.content contains ' +

{{- site.data.locales[include.lang].panel.toc -}}

+ + +{% endif %} diff --git a/_includes/topbar.html b/_includes/topbar.html new file mode 100644 index 0000000..fd68d1f --- /dev/null +++ b/_includes/topbar.html @@ -0,0 +1,77 @@ + + +
+
+ + + + + +
+ {% if page.layout == 'home' %} + {{- site.data.locales[include.lang].title | default: site.title -}} + {% elsif page.collection == 'tabs' or page.layout == 'page' %} + {%- capture tab_key -%}{{ page.url | split: '/' }}{%- endcapture -%} + {{- site.data.locales[include.lang].tabs[tab_key] | default: page.title -}} + {% else %} + {{- site.data.locales[include.lang].layout[page.layout] | default: page.layout | capitalize -}} + {% endif %} +
+ + + + + + + + +
+
diff --git a/_includes/trending-tags.html b/_includes/trending-tags.html new file mode 100644 index 0000000..57369f0 --- /dev/null +++ b/_includes/trending-tags.html @@ -0,0 +1,46 @@ + + +{% assign MAX = 10 %} + +{% assign size_list = '' | split: '' %} +{% assign tag_list = '' | split: '' %} + +{% for tag in site.tags %} + {% assign size = tag | last | size %} + {% assign size_list = size_list | push: size %} + + {% assign tag_str = tag | first | append: '::' | append: size %} + {% assign tag_list = tag_list | push: tag_str %} +{% endfor %} + +{% assign size_list = size_list | sort | reverse %} + +{% assign tag_list = tag_list | sort_natural %} + +{% assign trending_tags = '' | split: '' %} + +{% for size in size_list limit: MAX %} + {% for tag_str in tag_list %} + {% assign tag = tag_str | split: '::' %} + {% assign tag_name = tag | first %} + {% assign tag_size = tag | last | plus: 0 %} + {% if tag_size == size %} + {% unless trending_tags contains tag_name %} + {% assign trending_tags = trending_tags | push: tag_name %} + {% break %} + {% endunless %} + {% endif %} + {% endfor %} +{% endfor %} + +{% if trending_tags.size > 0 %} +
+

{{- site.data.locales[include.lang].panel.trending_tags -}}

+
+ {% for tag_name in trending_tags %} + {% assign url = tag_name | slugify | url_encode | prepend: '/tags/' | append: '/' %} + + {% endfor %} +
+
+{% endif %} diff --git a/_includes/update-list.html b/_includes/update-list.html new file mode 100644 index 0000000..93684c3 --- /dev/null +++ b/_includes/update-list.html @@ -0,0 +1,40 @@ + + +{% assign MAX_SIZE = 5 %} + +{% assign all_list = '' | split: '' %} + +{% for post in site.posts %} + {% assign datetime = post.last_modified_at | default: post.date %} + + {% capture elem %} + {{- datetime | date: "%Y%m%d%H%M%S" -}}::{{- forloop.index0 -}} + {% endcapture %} + + {% assign all_list = all_list | push: elem %} +{% endfor %} + +{% assign all_list = all_list | sort | reverse %} + +{% assign update_list = '' | split: '' %} + +{% for entry in all_list limit: MAX_SIZE %} + {% assign update_list = update_list | push: entry %} +{% endfor %} + +{% if update_list.size > 0 %} +
+

{{- site.data.locales[include.lang].panel.lastmod -}}

+
    + {% for item in update_list %} + {% assign index = item | split: '::' | last | plus: 0 %} + {% assign post = site.posts[index] %} + {% assign url = post.url | relative_url %} +
  • + {{ post.title }} +
  • + {% endfor %} +
+
+ +{% endif %} diff --git a/_javascript/categories.js b/_javascript/categories.js new file mode 100644 index 0000000..15d8251 --- /dev/null +++ b/_javascript/categories.js @@ -0,0 +1,7 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; +import { categoryCollapse } from './modules/plugins'; + +basic(); +initSidebar(); +initTopbar(); +categoryCollapse(); diff --git a/_javascript/commons.js b/_javascript/commons.js new file mode 100644 index 0000000..6a17fb9 --- /dev/null +++ b/_javascript/commons.js @@ -0,0 +1,5 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; + +initSidebar(); +initTopbar(); +basic(); diff --git a/_javascript/home.js b/_javascript/home.js new file mode 100644 index 0000000..ef22cb9 --- /dev/null +++ b/_javascript/home.js @@ -0,0 +1,8 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; +import { initLocaleDatetime, loadImg } from './modules/plugins'; + +loadImg(); +initLocaleDatetime(); +initSidebar(); +initTopbar(); +basic(); diff --git a/_javascript/misc.js b/_javascript/misc.js new file mode 100644 index 0000000..52b4043 --- /dev/null +++ b/_javascript/misc.js @@ -0,0 +1,7 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; +import { initLocaleDatetime } from './modules/plugins'; + +initSidebar(); +initTopbar(); +initLocaleDatetime(); +basic(); diff --git a/_javascript/modules/components/back-to-top.js b/_javascript/modules/components/back-to-top.js new file mode 100644 index 0000000..40d9cd1 --- /dev/null +++ b/_javascript/modules/components/back-to-top.js @@ -0,0 +1,19 @@ +/** + * Reference: https://bootsnipp.com/snippets/featured/link-to-top-page + */ + +export function back2top() { + const btn = document.getElementById('back-to-top'); + + window.addEventListener('scroll', () => { + if (window.scrollY > 50) { + btn.classList.add('show'); + } else { + btn.classList.remove('show'); + } + }); + + btn.addEventListener('click', () => { + window.scrollTo({ top: 0 }); + }); +} diff --git a/_javascript/modules/components/category-collapse.js b/_javascript/modules/components/category-collapse.js new file mode 100644 index 0000000..0c53cb4 --- /dev/null +++ b/_javascript/modules/components/category-collapse.js @@ -0,0 +1,36 @@ +/** + * Tab 'Categories' expand/close effect. + */ + +import 'bootstrap/js/src/collapse.js'; + +const childPrefix = 'l_'; +const parentPrefix = 'h_'; +const children = document.getElementsByClassName('collapse'); + +export function categoryCollapse() { + [...children].forEach((elem) => { + const id = parentPrefix + elem.id.substring(childPrefix.length); + const parent = document.getElementById(id); + + // collapse sub-categories + elem.addEventListener('hide.bs.collapse', () => { + if (parent) { + parent.querySelector('.far.fa-folder-open').className = + 'far fa-folder fa-fw'; + parent.querySelector('.fas.fa-angle-down').classList.add('rotate'); + parent.classList.remove('hide-border-bottom'); + } + }); + + // expand sub-categories + elem.addEventListener('show.bs.collapse', () => { + if (parent) { + parent.querySelector('.far.fa-folder').className = + 'far fa-folder-open fa-fw'; + parent.querySelector('.fas.fa-angle-down').classList.remove('rotate'); + parent.classList.add('hide-border-bottom'); + } + }); + }); +} diff --git a/_javascript/modules/components/clipboard.js b/_javascript/modules/components/clipboard.js new file mode 100644 index 0000000..9566e9d --- /dev/null +++ b/_javascript/modules/components/clipboard.js @@ -0,0 +1,143 @@ +/** + * Clipboard functions + * + * Dependencies: + * clipboard.js (https://github.com/zenorocha/clipboard.js) + */ + +import Tooltip from 'bootstrap/js/src/tooltip'; + +const clipboardSelector = '.code-header>button'; + +const ICON_DEFAULT = 'far fa-clipboard'; +const ICON_SUCCESS = 'fas fa-check'; + +const ATTR_TIMEOUT = 'timeout'; +const ATTR_TITLE_SUCCEED = 'data-title-succeed'; +const ATTR_TITLE_ORIGIN = 'data-bs-original-title'; +const TIMEOUT = 2000; // in milliseconds + +function isLocked(node) { + if (node.hasAttribute(ATTR_TIMEOUT)) { + let timeout = node.getAttribute(ATTR_TIMEOUT); + if (Number(timeout) > Date.now()) { + return true; + } + } + + return false; +} + +function lock(node) { + node.setAttribute(ATTR_TIMEOUT, Date.now() + TIMEOUT); +} + +function unlock(node) { + node.removeAttribute(ATTR_TIMEOUT); +} + +function showTooltip(btn) { + const succeedTitle = btn.getAttribute(ATTR_TITLE_SUCCEED); + btn.setAttribute(ATTR_TITLE_ORIGIN, succeedTitle); + Tooltip.getInstance(btn).show(); +} + +function hideTooltip(btn) { + Tooltip.getInstance(btn).hide(); + btn.removeAttribute(ATTR_TITLE_ORIGIN); +} + +function setSuccessIcon(btn) { + const icon = btn.children[0]; + icon.setAttribute('class', ICON_SUCCESS); +} + +function resumeIcon(btn) { + const icon = btn.children[0]; + icon.setAttribute('class', ICON_DEFAULT); +} + +function setCodeClipboard() { + const clipboardList = document.querySelectorAll(clipboardSelector); + + if (clipboardList.length === 0) { + return; + } + + // Initial the clipboard.js object + const clipboard = new ClipboardJS(clipboardSelector, { + target: (trigger) => { + const codeBlock = trigger.parentNode.nextElementSibling; + return codeBlock.querySelector('code .rouge-code'); + } + }); + + [...clipboardList].map( + (elem) => + new Tooltip(elem, { + placement: 'left' + }) + ); + + clipboard.on('success', (e) => { + const trigger = e.trigger; + + e.clearSelection(); + + if (isLocked(trigger)) { + return; + } + + setSuccessIcon(trigger); + showTooltip(trigger); + lock(trigger); + + setTimeout(() => { + hideTooltip(trigger); + resumeIcon(trigger); + unlock(trigger); + }, TIMEOUT); + }); +} + +function setLinkClipboard() { + const btnCopyLink = document.getElementById('copy-link'); + + if (btnCopyLink === null) { + return; + } + + btnCopyLink.addEventListener('click', (e) => { + const target = e.target; + + if (isLocked(target)) { + return; + } + + // Copy URL to clipboard + navigator.clipboard.writeText(window.location.href).then(() => { + const defaultTitle = target.getAttribute(ATTR_TITLE_ORIGIN); + const succeedTitle = target.getAttribute(ATTR_TITLE_SUCCEED); + + // Switch tooltip title + target.setAttribute(ATTR_TITLE_ORIGIN, succeedTitle); + Tooltip.getInstance(target).show(); + + lock(target); + + setTimeout(() => { + target.setAttribute(ATTR_TITLE_ORIGIN, defaultTitle); + unlock(target); + }, TIMEOUT); + }); + }); + + btnCopyLink.addEventListener('mouseleave', (e) => { + Tooltip.getInstance(e.target).hide(); + }); +} + +export function initClipboard() { + setCodeClipboard(); + setLinkClipboard(); +} diff --git a/_javascript/modules/components/img-loading.js b/_javascript/modules/components/img-loading.js new file mode 100644 index 0000000..989d9e6 --- /dev/null +++ b/_javascript/modules/components/img-loading.js @@ -0,0 +1,67 @@ +/** + * Setting up image lazy loading and LQIP switching + */ + +const ATTR_DATA_SRC = 'data-src'; +const ATTR_DATA_LQIP = 'data-lqip'; + +const cover = { + SHIMMER: 'shimmer', + BLUR: 'blur' +}; + +function removeCover(clzss) { + this.parentElement.classList.remove(clzss); +} + +function handleImage() { + if (!this.complete) { + return; + } + + if (this.hasAttribute(ATTR_DATA_LQIP)) { + removeCover.call(this, cover.BLUR); + } else { + removeCover.call(this, cover.SHIMMER); + } +} + +/** + * Switches the LQIP with the real image URL. + */ +function switchLQIP() { + const src = this.getAttribute(ATTR_DATA_SRC); + this.setAttribute('src', encodeURI(src)); + this.removeAttribute(ATTR_DATA_SRC); +} + +export function loadImg() { + const images = document.querySelectorAll('article img'); + + if (images.length === 0) { + return; + } + + images.forEach((img) => { + img.addEventListener('load', handleImage); + }); + + // Images loaded from the browser cache do not trigger the 'load' event + document.querySelectorAll('article img[loading="lazy"]').forEach((img) => { + if (img.complete) { + removeCover.call(img, cover.SHIMMER); + } + }); + + // LQIPs set by the data URI or WebP will not trigger the 'load' event, + // so manually convert the URI to the URL of a high-resolution image. + const lqips = document.querySelectorAll( + `article img[${ATTR_DATA_LQIP}="true"]` + ); + + if (lqips.length) { + lqips.forEach((lqip) => { + switchLQIP.call(lqip); + }); + } +} diff --git a/_javascript/modules/components/img-popup.js b/_javascript/modules/components/img-popup.js new file mode 100644 index 0000000..ac12043 --- /dev/null +++ b/_javascript/modules/components/img-popup.js @@ -0,0 +1,60 @@ +/** + * Set up image popup + * + * Dependencies: https://github.com/biati-digital/glightbox + */ + +const html = document.documentElement; +const lightImages = '.popup:not(.dark)'; +const darkImages = '.popup:not(.light)'; +let selector = lightImages; + +function updateImages(current, reverse) { + if (selector === lightImages) { + selector = darkImages; + } else { + selector = lightImages; + } + + if (reverse === null) { + reverse = GLightbox({ selector: `${selector}` }); + } + + [current, reverse] = [reverse, current]; +} + +export function imgPopup() { + if (document.querySelector('.popup') === null) { + return; + } + + const hasDualImages = !( + document.querySelector('.popup.light') === null && + document.querySelector('.popup.dark') === null + ); + + if ( + (html.hasAttribute('data-mode') && + html.getAttribute('data-mode') === 'dark') || + (!html.hasAttribute('data-mode') && + window.matchMedia('(prefers-color-scheme: dark)').matches) + ) { + selector = darkImages; + } + + let current = GLightbox({ selector: `${selector}` }); + + if (hasDualImages && document.getElementById('mode-toggle')) { + let reverse = null; + + window.addEventListener('message', (event) => { + if ( + event.source === window && + event.data && + event.data.direction === ModeToggle.ID + ) { + updateImages(current, reverse); + } + }); + } +} diff --git a/_javascript/modules/components/locale-datetime.js b/_javascript/modules/components/locale-datetime.js new file mode 100644 index 0000000..eb75626 --- /dev/null +++ b/_javascript/modules/components/locale-datetime.js @@ -0,0 +1,53 @@ +/** + * Update month/day to locale datetime + * + * Requirement: + */ + +/* A tool for locale datetime */ +class LocaleHelper { + static get attrTimestamp() { + return 'data-ts'; + } + + static get attrDateFormat() { + return 'data-df'; + } + + static get locale() { + return document.documentElement.getAttribute('lang').substring(0, 2); + } + + static getTimestamp(elem) { + return Number(elem.getAttribute(this.attrTimestamp)); // unix timestamp + } + + static getDateFormat(elem) { + return elem.getAttribute(this.attrDateFormat); + } +} + +export function initLocaleDatetime() { + dayjs.locale(LocaleHelper.locale); + dayjs.extend(window.dayjs_plugin_localizedFormat); + + document + .querySelectorAll(`[${LocaleHelper.attrTimestamp}]`) + .forEach((elem) => { + const date = dayjs.unix(LocaleHelper.getTimestamp(elem)); + const text = date.format(LocaleHelper.getDateFormat(elem)); + elem.textContent = text; + elem.removeAttribute(LocaleHelper.attrTimestamp); + elem.removeAttribute(LocaleHelper.attrDateFormat); + + // setup tooltips + if ( + elem.hasAttribute('data-bs-toggle') && + elem.getAttribute('data-bs-toggle') === 'tooltip' + ) { + // see: https://day.js.org/docs/en/display/format#list-of-localized-formats + const tooltipText = date.format('llll'); + elem.setAttribute('data-bs-title', tooltipText); + } + }); +} diff --git a/_javascript/modules/components/mode-watcher.js b/_javascript/modules/components/mode-watcher.js new file mode 100644 index 0000000..9eecd09 --- /dev/null +++ b/_javascript/modules/components/mode-watcher.js @@ -0,0 +1,14 @@ +/** + * Add listener for theme mode toggle + */ +const toggle = document.getElementById('mode-toggle'); + +export function modeWatcher() { + if (!toggle) { + return; + } + + toggle.addEventListener('click', () => { + modeToggle.flipMode(); + }); +} diff --git a/_javascript/modules/components/search-display.js b/_javascript/modules/components/search-display.js new file mode 100644 index 0000000..40059ac --- /dev/null +++ b/_javascript/modules/components/search-display.js @@ -0,0 +1,110 @@ +/** + * This script make #search-result-wrapper switch to unload or shown automatically. + */ + +const btnSbTrigger = document.getElementById('sidebar-trigger'); +const btnSearchTrigger = document.getElementById('search-trigger'); +const btnCancel = document.getElementById('search-cancel'); +const content = document.querySelectorAll('#main-wrapper>.container>.row'); +const topbarTitle = document.getElementById('topbar-title'); +const search = document.getElementById('search'); +const resultWrapper = document.getElementById('search-result-wrapper'); +const results = document.getElementById('search-results'); +const input = document.getElementById('search-input'); +const hints = document.getElementById('search-hints'); + +// CSS class names +const LOADED = 'd-block'; +const UNLOADED = 'd-none'; +const FOCUS = 'input-focus'; +const FLEX = 'd-flex'; + +/* Actions in mobile screens (Sidebar hidden) */ +class MobileSearchBar { + static on() { + btnSbTrigger.classList.add(UNLOADED); + topbarTitle.classList.add(UNLOADED); + btnSearchTrigger.classList.add(UNLOADED); + search.classList.add(FLEX); + btnCancel.classList.add(LOADED); + } + + static off() { + btnCancel.classList.remove(LOADED); + search.classList.remove(FLEX); + btnSbTrigger.classList.remove(UNLOADED); + topbarTitle.classList.remove(UNLOADED); + btnSearchTrigger.classList.remove(UNLOADED); + } +} + +class ResultSwitch { + static resultVisible = false; + + static on() { + if (!this.resultVisible) { + resultWrapper.classList.remove(UNLOADED); + content.forEach((el) => { + el.classList.add(UNLOADED); + }); + this.resultVisible = true; + } + } + + static off() { + if (this.resultVisible) { + results.innerHTML = ''; + + if (hints.classList.contains(UNLOADED)) { + hints.classList.remove(UNLOADED); + } + + resultWrapper.classList.add(UNLOADED); + content.forEach((el) => { + el.classList.remove(UNLOADED); + }); + input.textContent = ''; + this.resultVisible = false; + } + } +} + +function isMobileView() { + return btnCancel.classList.contains(LOADED); +} + +export function displaySearch() { + btnSearchTrigger.addEventListener('click', () => { + MobileSearchBar.on(); + ResultSwitch.on(); + input.focus(); + }); + + btnCancel.addEventListener('click', () => { + MobileSearchBar.off(); + ResultSwitch.off(); + }); + + input.addEventListener('focus', () => { + search.classList.add(FOCUS); + }); + + input.addEventListener('focusout', () => { + search.classList.remove(FOCUS); + }); + + input.addEventListener('input', () => { + if (input.value === '') { + if (isMobileView()) { + hints.classList.remove(UNLOADED); + } else { + ResultSwitch.off(); + } + } else { + ResultSwitch.on(); + if (isMobileView()) { + hints.classList.add(UNLOADED); + } + } + }); +} diff --git a/_javascript/modules/components/sidebar.js b/_javascript/modules/components/sidebar.js new file mode 100644 index 0000000..6b562d8 --- /dev/null +++ b/_javascript/modules/components/sidebar.js @@ -0,0 +1,27 @@ +/** + * Expand or close the sidebar in mobile screens. + */ + +const ATTR_DISPLAY = 'sidebar-display'; + +class SidebarUtil { + static isExpanded = false; + + static toggle() { + if (SidebarUtil.isExpanded === false) { + document.body.setAttribute(ATTR_DISPLAY, ''); + } else { + document.body.removeAttribute(ATTR_DISPLAY); + } + + SidebarUtil.isExpanded = !SidebarUtil.isExpanded; + } +} + +export function sidebarExpand() { + document + .getElementById('sidebar-trigger') + .addEventListener('click', SidebarUtil.toggle); + + document.getElementById('mask').addEventListener('click', SidebarUtil.toggle); +} diff --git a/_javascript/modules/components/toc.js b/_javascript/modules/components/toc.js new file mode 100644 index 0000000..56ce26f --- /dev/null +++ b/_javascript/modules/components/toc.js @@ -0,0 +1,15 @@ +export function toc() { + if (document.querySelector('main h2, main h3')) { + // see: https://github.com/tscanlin/tocbot#usage + tocbot.init({ + tocSelector: '#toc', + contentSelector: '.content', + ignoreSelector: '[data-toc-skip]', + headingSelector: 'h2, h3, h4', + orderedList: false, + scrollSmooth: false + }); + + document.getElementById('toc-wrapper').classList.remove('d-none'); + } +} diff --git a/_javascript/modules/components/tooltip-loader.js b/_javascript/modules/components/tooltip-loader.js new file mode 100644 index 0000000..c36c879 --- /dev/null +++ b/_javascript/modules/components/tooltip-loader.js @@ -0,0 +1,11 @@ +import Tooltip from 'bootstrap/js/src/tooltip'; + +export function loadTooptip() { + const tooltipTriggerList = document.querySelectorAll( + '[data-bs-toggle="tooltip"]' + ); + + [...tooltipTriggerList].map( + (tooltipTriggerEl) => new Tooltip(tooltipTriggerEl) + ); +} diff --git a/_javascript/modules/layouts.js b/_javascript/modules/layouts.js new file mode 100644 index 0000000..28f7962 --- /dev/null +++ b/_javascript/modules/layouts.js @@ -0,0 +1,3 @@ +export { basic } from './layouts/basic'; +export { initSidebar } from './layouts/sidebar'; +export { initTopbar } from './layouts/topbar'; diff --git a/_javascript/modules/layouts/basic.js b/_javascript/modules/layouts/basic.js new file mode 100644 index 0000000..fb36a8b --- /dev/null +++ b/_javascript/modules/layouts/basic.js @@ -0,0 +1,7 @@ +import { back2top } from '../components/back-to-top'; +import { loadTooptip } from '../components/tooltip-loader'; + +export function basic() { + back2top(); + loadTooptip(); +} diff --git a/_javascript/modules/layouts/sidebar.js b/_javascript/modules/layouts/sidebar.js new file mode 100644 index 0000000..8795693 --- /dev/null +++ b/_javascript/modules/layouts/sidebar.js @@ -0,0 +1,7 @@ +import { modeWatcher } from '../components/mode-watcher'; +import { sidebarExpand } from '../components/sidebar'; + +export function initSidebar() { + modeWatcher(); + sidebarExpand(); +} diff --git a/_javascript/modules/layouts/topbar.js b/_javascript/modules/layouts/topbar.js new file mode 100644 index 0000000..cfcd0ed --- /dev/null +++ b/_javascript/modules/layouts/topbar.js @@ -0,0 +1,5 @@ +import { displaySearch } from '../components/search-display'; + +export function initTopbar() { + displaySearch(); +} diff --git a/_javascript/modules/plugins.js b/_javascript/modules/plugins.js new file mode 100644 index 0000000..fb892e2 --- /dev/null +++ b/_javascript/modules/plugins.js @@ -0,0 +1,6 @@ +export { categoryCollapse } from './components/category-collapse'; +export { initClipboard } from './components/clipboard'; +export { loadImg } from './components/img-loading'; +export { imgPopup } from './components/img-popup'; +export { initLocaleDatetime } from './components/locale-datetime'; +export { toc } from './components/toc'; diff --git a/_javascript/page.js b/_javascript/page.js new file mode 100644 index 0000000..76e8ce9 --- /dev/null +++ b/_javascript/page.js @@ -0,0 +1,9 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; +import { loadImg, imgPopup, initClipboard } from './modules/plugins'; + +loadImg(); +imgPopup(); +initSidebar(); +initTopbar(); +initClipboard(); +basic(); diff --git a/_javascript/post.js b/_javascript/post.js new file mode 100644 index 0000000..9340f05 --- /dev/null +++ b/_javascript/post.js @@ -0,0 +1,17 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; +import { + loadImg, + imgPopup, + initLocaleDatetime, + initClipboard, + toc +} from './modules/plugins'; + +loadImg(); +toc(); +imgPopup(); +initSidebar(); +initLocaleDatetime(); +initClipboard(); +initTopbar(); +basic(); diff --git a/_javascript/pwa/app.js b/_javascript/pwa/app.js new file mode 100644 index 0000000..3c0ded2 --- /dev/null +++ b/_javascript/pwa/app.js @@ -0,0 +1,55 @@ +import Toast from 'bootstrap/js/src/toast'; + +if ('serviceWorker' in navigator) { + // Get Jekyll config from URL parameters + const src = new URL(document.currentScript.src); + const register = src.searchParams.get('register'); + const baseUrl = src.searchParams.get('baseurl'); + + if (register) { + const swUrl = `${baseUrl}/sw.min.js`; + const notification = document.getElementById('notification'); + const btnRefresh = notification.querySelector('.toast-body>button'); + const popupWindow = Toast.getOrCreateInstance(notification); + + navigator.serviceWorker.register(swUrl).then((registration) => { + // Restore the update window that was last manually closed by the user + if (registration.waiting) { + popupWindow.show(); + } + + registration.addEventListener('updatefound', () => { + registration.installing.addEventListener('statechange', () => { + if (registration.waiting) { + if (navigator.serviceWorker.controller) { + popupWindow.show(); + } + } + }); + }); + + btnRefresh.addEventListener('click', () => { + if (registration.waiting) { + registration.waiting.postMessage('SKIP_WAITING'); + } + popupWindow.hide(); + }); + }); + + let refreshing = false; + + // Detect controller change and refresh all the opened tabs + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!refreshing) { + window.location.reload(); + refreshing = true; + } + }); + } else { + navigator.serviceWorker.getRegistrations().then(function (registrations) { + for (let registration of registrations) { + registration.unregister(); + } + }); + } +} diff --git a/_javascript/pwa/sw.js b/_javascript/pwa/sw.js new file mode 100644 index 0000000..ff9125d --- /dev/null +++ b/_javascript/pwa/sw.js @@ -0,0 +1,92 @@ +importScripts('./assets/js/data/swconf.js'); + +const purge = swconf.purge; +const interceptor = swconf.interceptor; + +function verifyUrl(url) { + const requestUrl = new URL(url); + const requestPath = requestUrl.pathname; + + if (!requestUrl.protocol.startsWith('http')) { + return false; + } + + for (const prefix of interceptor.urlPrefixes) { + if (requestUrl.href.startsWith(prefix)) { + return false; + } + } + + for (const path of interceptor.paths) { + if (requestPath.startsWith(path)) { + return false; + } + } + return true; +} + +self.addEventListener('install', (event) => { + if (purge) { + return; + } + + event.waitUntil( + caches.open(swconf.cacheName).then((cache) => { + return cache.addAll(swconf.resources); + }) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keyList) => { + return Promise.all( + keyList.map((key) => { + if (purge) { + return caches.delete(key); + } else { + if (key !== swconf.cacheName) { + return caches.delete(key); + } + } + }) + ); + }) + ); +}); + +self.addEventListener('message', (event) => { + if (event.data === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +self.addEventListener('fetch', (event) => { + if (event.request.headers.has('range')) { + return; + } + + event.respondWith( + caches.match(event.request).then((response) => { + if (response) { + return response; + } + + return fetch(event.request).then((response) => { + const url = event.request.url; + + if (purge || event.request.method !== 'GET' || !verifyUrl(url)) { + return response; + } + + // See: + let responseToCache = response.clone(); + + caches.open(swconf.cacheName).then((cache) => { + cache.put(event.request, responseToCache); + }); + return response; + }); + }) + ); +}); diff --git a/_layouts/archives.html b/_layouts/archives.html new file mode 100644 index 0000000..4f7ad7d --- /dev/null +++ b/_layouts/archives.html @@ -0,0 +1,35 @@ +--- +layout: page +# The Archives of posts. +--- + +{% include lang.html %} + +{% assign df_strftime_m = site.data.locales[lang].df.archives.strftime | default: '/ %m' %} +{% assign df_dayjs_m = site.data.locales[lang].df.archives.dayjs | default: '/ MM' %} + +
+ {% for post in site.posts %} + {% assign cur_year = post.date | date: '%Y' %} + + {% if cur_year != last_year %} + {% unless forloop.first %}{% endunless %} + + + {{ '
    ' }} + + {% assign last_year = cur_year %} + {% endif %} + +
  • + {% assign ts = post.date | date: '%s' %} + {{ post.date | date: '%d' }} + + {{ post.date | date: df_strftime_m }} + + {{ post.title }} +
  • + + {% if forloop.last %}
{% endif %} + {% endfor %} +
diff --git a/_layouts/categories.html b/_layouts/categories.html new file mode 100644 index 0000000..0515097 --- /dev/null +++ b/_layouts/categories.html @@ -0,0 +1,138 @@ +--- +layout: page +# All the Categories of posts +--- + +{% include lang.html %} + +{% assign HEAD_PREFIX = 'h_' %} +{% assign LIST_PREFIX = 'l_' %} + +{% assign group_index = 0 %} + +{% assign sort_categories = site.categories | sort %} + +{% for category in sort_categories %} + {% assign category_name = category | first %} + {% assign posts_of_category = category | last %} + {% assign first_post = posts_of_category | first %} + + {% if category_name == first_post.categories[0] %} + {% assign sub_categories = '' | split: '' %} + + {% for post in posts_of_category %} + {% assign second_category = post.categories[1] %} + {% if second_category %} + {% unless sub_categories contains second_category %} + {% assign sub_categories = sub_categories | push: second_category %} + {% endunless %} + {% endif %} + {% endfor %} + + {% assign sub_categories = sub_categories | sort %} + {% assign sub_categories_size = sub_categories | size %} + +
+ +
+ + + + {% capture _category_url %}/categories/{{ category_name | slugify | url_encode }}/{% endcapture %} + {{ category_name }} + + + {% assign top_posts_size = site.categories[category_name] | size %} + + {% if sub_categories_size > 0 %} + {{ sub_categories_size }} + {% if sub_categories_size > 1 %} + {{ + site.data.locales[lang].categories.category_measure.plural + | default: site.data.locales[lang].categories.category_measure + }} + {% else %} + {{ + site.data.locales[lang].categories.category_measure.singular + | default: site.data.locales[lang].categories.category_measure + }} + {% endif -%} + , + {% endif %} + + {{ top_posts_size }} + + {% if top_posts_size > 1 %} + {{ + site.data.locales[lang].categories.post_measure.plural + | default: site.data.locales[lang].categories.post_measure + }} + {% else %} + {{ + site.data.locales[lang].categories.post_measure.singular + | default: site.data.locales[lang].categories.post_measure + }} + {% endif %} + + + + + {% if sub_categories_size > 0 %} + + + + {% else %} + + + + {% endif %} +
+ + + + {% if sub_categories_size > 0 %} +
+
    + {% for sub_category in sub_categories %} +
  • + + + {% capture _sub_ctg_url %}/categories/{{ sub_category | slugify | url_encode }}/{% endcapture %} + {{ sub_category }} + + {% assign posts_size = site.categories[sub_category] | size %} + + {{ posts_size }} + + {% if posts_size > 1 %} + {{ + site.data.locales[lang].categories.post_measure.plural + | default: site.data.locales[lang].categories.post_measure + }} + {% else %} + {{ + site.data.locales[lang].categories.post_measure.singular + | default: site.data.locales[lang].categories.post_measure + }} + {% endif %} + +
  • + {% endfor %} +
+
+ {% endif %} +
+ + + {% assign group_index = group_index | plus: 1 %} + {% endif %} +{% endfor %} diff --git a/_layouts/category.html b/_layouts/category.html new file mode 100644 index 0000000..b064f27 --- /dev/null +++ b/_layouts/category.html @@ -0,0 +1,24 @@ +--- +layout: page +# The Category layout +--- + +{% include lang.html %} + +
+

+ + {{ page.title }} + {{ page.posts | size }} +

+ +
    + {% for post in page.posts %} +
  • + {{ post.title }} + + {% include datetime.html date=post.date class='text-muted small text-nowrap' lang=lang %} +
  • + {% endfor %} +
+
diff --git a/_layouts/compress.html b/_layouts/compress.html new file mode 100644 index 0000000..2779e92 --- /dev/null +++ b/_layouts/compress.html @@ -0,0 +1,10 @@ +--- +# Jekyll layout that compresses HTML +# v3.2.0 +# http://jch.penibelst.de/ +# © 2014–2015 Anatol Broder +# MIT License +--- + +{% capture _LINE_FEED %} +{% endcapture %}{% if site.compress_html.ignore.envs contains jekyll.environment or site.compress_html.ignore.envs == "all" or page.compress_html == false %}{{ content }}{% else %}{% capture _content %}{{ content }}{% endcapture %}{% assign _profile = site.compress_html.profile %}{% if site.compress_html.endings == "all" %}{% assign _endings = "html head body li dt dd optgroup option colgroup caption thead tbody tfoot tr td th" | split: " " %}{% else %}{% assign _endings = site.compress_html.endings %}{% endif %}{% for _element in _endings %}{% capture _end %}{% endcapture %}{% assign _content = _content | remove: _end %}{% endfor %}{% if _profile and _endings %}{% assign _profile_endings = _content | size | plus: 1 %}{% endif %}{% for _element in site.compress_html.startings %}{% capture _start %}<{{ _element }}>{% endcapture %}{% assign _content = _content | remove: _start %}{% endfor %}{% if _profile and site.compress_html.startings %}{% assign _profile_startings = _content | size | plus: 1 %}{% endif %}{% if site.compress_html.comments == "all" %}{% assign _comments = "" | split: " " %}{% else %}{% assign _comments = site.compress_html.comments %}{% endif %}{% if _comments.size == 2 %}{% capture _comment_befores %}.{{ _content }}{% endcapture %}{% assign _comment_befores = _comment_befores | split: _comments.first %}{% for _comment_before in _comment_befores %}{% if forloop.first %}{% continue %}{% endif %}{% capture _comment_outside %}{% if _carry %}{{ _comments.first }}{% endif %}{{ _comment_before }}{% endcapture %}{% capture _comment %}{% unless _carry %}{{ _comments.first }}{% endunless %}{{ _comment_outside | split: _comments.last | first }}{% if _comment_outside contains _comments.last %}{{ _comments.last }}{% assign _carry = false %}{% else %}{% assign _carry = true %}{% endif %}{% endcapture %}{% assign _content = _content | remove_first: _comment %}{% endfor %}{% if _profile %}{% assign _profile_comments = _content | size | plus: 1 %}{% endif %}{% endif %}{% assign _pre_befores = _content | split: "" %}{% assign _pres_after = "" %}{% if _pres.size != 0 %}{% if site.compress_html.blanklines %}{% assign _lines = _pres.last | split: _LINE_FEED %}{% capture _pres_after %}{% for _line in _lines %}{% assign _trimmed = _line | split: " " | join: " " %}{% if _trimmed != empty or forloop.last %}{% unless forloop.first %}{{ _LINE_FEED }}{% endunless %}{{ _line }}{% endif %}{% endfor %}{% endcapture %}{% else %}{% assign _pres_after = _pres.last | split: " " | join: " " %}{% endif %}{% endif %}{% capture _content %}{{ _content }}{% if _pre_before contains "
" %}{% endif %}{% unless _pre_before contains "
" and _pres.size == 1 %}{{ _pres_after }}{% endunless %}{% endcapture %}{% endfor %}{% if _profile %}{% assign _profile_collapse = _content | size | plus: 1 %}{% endif %}{% if site.compress_html.clippings == "all" %}{% assign _clippings = "html head title base link meta style body article section nav aside h1 h2 h3 h4 h5 h6 hgroup header footer address p hr blockquote ol ul li dl dt dd figure figcaption main div table caption colgroup col tbody thead tfoot tr td th" | split: " " %}{% else %}{% assign _clippings = site.compress_html.clippings %}{% endif %}{% for _element in _clippings %}{% assign _edges = " ;; ;" | replace: "e", _element | split: ";" %}{% assign _content = _content | replace: _edges[0], _edges[1] | replace: _edges[2], _edges[3] | replace: _edges[4], _edges[5] %}{% endfor %}{% if _profile and _clippings %}{% assign _profile_clippings = _content | size | plus: 1 %}{% endif %}{{ _content }}{% if _profile %}
Step Bytes
raw {{ content | size }}{% if _profile_endings %}
endings {{ _profile_endings }}{% endif %}{% if _profile_startings %}
startings {{ _profile_startings }}{% endif %}{% if _profile_comments %}
comments {{ _profile_comments }}{% endif %}{% if _profile_collapse %}
collapse {{ _profile_collapse }}{% endif %}{% if _profile_clippings %}
clippings {{ _profile_clippings }}{% endif %}
{% endif %}{% endif %} diff --git a/_layouts/default.html b/_layouts/default.html new file mode 100644 index 0000000..ea438fe --- /dev/null +++ b/_layouts/default.html @@ -0,0 +1,82 @@ +--- +layout: compress +--- + + + +{% include origin-type.html %} + +{% include lang.html %} + +{% if site.theme_mode %} + {% capture prefer_mode %}data-mode="{{ site.theme_mode }}"{% endcapture %} +{% endif %} + + + + {% include head.html %} + + + {% include sidebar.html lang=lang %} + +
+
+ {% include topbar.html lang=lang %} + +
+
+ {% if layout.refactor or layout.layout == 'default' %} + {% include refactor-content.html content=content lang=lang %} + {% else %} + {{ content }} + {% endif %} +
+ + + +
+ +
+ +
+ {% for _include in layout.tail_includes %} + {% assign _include_path = _include | append: '.html' %} + {% include {{ _include_path }} lang=lang %} + {% endfor %} + + {% include_cached footer.html lang=lang %} +
+
+ + {% include_cached search-results.html lang=lang %} +
+ + +
+ +
+ + {% if site.pwa.enabled %} + {% include_cached notification.html lang=lang %} + {% endif %} + + + {% include js-selector.html lang=lang %} + + {% include_cached search-loader.html lang=lang %} + + diff --git a/_layouts/home.html b/_layouts/home.html new file mode 100644 index 0000000..e44efe8 --- /dev/null +++ b/_layouts/home.html @@ -0,0 +1,115 @@ +--- +layout: default +refactor: true +--- + +{% include lang.html %} + +{% assign pinned = site.posts | where: 'pin', 'true' %} +{% assign default = site.posts | where_exp: 'item', 'item.pin != true and item.hidden != true' %} + +{% assign posts = '' | split: '' %} + + + +{% assign offset = paginator.page | minus: 1 | times: paginator.per_page %} +{% assign pinned_num = pinned.size | minus: offset %} + +{% if pinned_num > 0 %} + {% for i in (offset..pinned.size) limit: pinned_num %} + {% assign posts = posts | push: pinned[i] %} + {% endfor %} +{% else %} + {% assign pinned_num = 0 %} +{% endif %} + + + +{% assign default_beg = offset | minus: pinned.size %} + +{% if default_beg < 0 %} + {% assign default_beg = 0 %} +{% endif %} + +{% assign default_num = paginator.posts | size | minus: pinned_num %} +{% assign default_end = default_beg | plus: default_num | minus: 1 %} + +{% if default_num > 0 %} + {% for i in (default_beg..default_end) %} + {% assign posts = posts | push: default[i] %} + {% endfor %} +{% endif %} + +
+ {% for post in posts %} +
+ + {% assign card_body_col = '12' %} + + {% if post.image %} + {% assign src = post.image.path | default: post.image %} + {% unless src contains '//' %} + {% assign src = post.media_subpath | append: '/' | append: src | replace: '//', '/' %} + {% endunless %} + + {% assign alt = post.image.alt | xml_escape | default: 'Preview Image' %} + + {% assign lqip = null %} + + {% if post.image.lqip %} + {% capture lqip %}lqip="{{ post.image.lqip }}"{% endcapture %} + {% endif %} + +
+ {{ alt }} +
+ + {% assign card_body_col = '7' %} + {% endif %} + +
+
+

{{ post.title }}

+ +
+

{% include post-description.html %}

+
+ + + +
+ +
+
+
+ {% endfor %} +
+ + +{% if paginator.total_pages > 1 %} + {% include post-paginator.html %} +{% endif %} diff --git a/_layouts/page.html b/_layouts/page.html new file mode 100644 index 0000000..32d6582 --- /dev/null +++ b/_layouts/page.html @@ -0,0 +1,20 @@ +--- +layout: default +--- + +{% include lang.html %} + +
+ {% if page.layout == 'page' or page.collection == 'tabs' %} + {% assign tab_key = page.title | downcase %} + {% assign title = site.data.locales[lang].tabs[tab_key] | default: page.title %} +

+ {{ title }} +

+
+ {{ content }} +
+ {% else %} + {{ content }} + {% endif %} +
diff --git a/_layouts/post.html b/_layouts/post.html new file mode 100644 index 0000000..f17ceea --- /dev/null +++ b/_layouts/post.html @@ -0,0 +1,152 @@ +--- +layout: default +refactor: true +panel_includes: + - toc +tail_includes: + - related-posts + - post-nav + - comments +--- + +{% include lang.html %} + +
+
+

{{ page.title }}

+ {% if page.description %} +

{{ page.description }}

+ {% endif %} + + +
+ +
+ {{ content }} +
+ +
+ + {% if page.categories.size > 0 %} + + {% endif %} + + + {% if page.tags.size > 0 %} + + {% endif %} + +
+
+ {% if site.data.locales[lang].copyright.license.template %} + {% capture _replacement %} + + {{ site.data.locales[lang].copyright.license.name }} + + {% endcapture %} + + {{ site.data.locales[lang].copyright.license.template | replace: ':LICENSE_NAME', _replacement }} + {% endif %} +
+ + {% include post-sharing.html lang=lang %} +
+ +
+ +
diff --git a/_layouts/tag.html b/_layouts/tag.html new file mode 100644 index 0000000..d766d09 --- /dev/null +++ b/_layouts/tag.html @@ -0,0 +1,23 @@ +--- +layout: page +# The layout for Tag page +--- + +{% include lang.html %} + +
+

+ + {{ page.title }} + {{ page.posts | size }} +

+
    + {% for post in page.posts %} +
  • + {{ post.title }} + + {% include datetime.html date=post.date class='text-muted small text-nowrap' lang=lang %} +
  • + {% endfor %} +
+
diff --git a/_layouts/tags.html b/_layouts/tags.html new file mode 100644 index 0000000..7800ca0 --- /dev/null +++ b/_layouts/tags.html @@ -0,0 +1,22 @@ +--- +layout: page +# All the Tags of posts. +--- + +
+ {% assign tags = '' | split: '' %} + {% for t in site.tags %} + {% assign tags = tags | push: t[0] %} + {% endfor %} + + {% assign sorted_tags = tags | sort_natural %} + + {% for t in sorted_tags %} + + {% endfor %} +
diff --git a/_plugins/posts-lastmod-hook.rb b/_plugins/posts-lastmod-hook.rb new file mode 100644 index 0000000..1fd6ecf --- /dev/null +++ b/_plugins/posts-lastmod-hook.rb @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby +# +# Check for changed posts + +Jekyll::Hooks.register :posts, :post_init do |post| + + commit_num = `git rev-list --count HEAD "#{ post.path }"` + + if commit_num.to_i > 1 + lastmod_date = `git log -1 --pretty="%ad" --date=iso "#{ post.path }"` + post.data['last_modified_at'] = lastmod_date + end + +end diff --git a/_posts/2020-12-08-extends-template.md b/_posts/2020-12-08-extends-template.md new file mode 100644 index 0000000..24f5840 --- /dev/null +++ b/_posts/2020-12-08-extends-template.md @@ -0,0 +1,281 @@ +--- +title: 'Azure DevOps: Extends Template with Build and Deployment Templates' +author: Josh Johanning +date: 2020-12-10 22:00:00 -0600 +categories: [Azure DevOps, Pipelines] +tags: [Azure DevOps, Pipeline Templates] +--- + +## Scenario + +I had a client that wanted to integrate a secret scanning utility (among other checks) into the pipeline, and enforce this control. Colin Dembovsky (my co-worker) and I typically recommend creating and referencing job templates for each environment. Job templates are very flexible, allowing for re-use across an organization but still allowing for differences between applications through the parameters passed into the template. + +A very abbreviated example of this would look like: + +```yaml +resources: + repositories: + - repository: templates + type: github + name: joshjohanning/pipeline-templates + endpoint: joshjohanning + +stages: +- stage: 'Build' + jobs: + - template: build.yml@templates + parameters: + buildConfiguration: 'release' +- stage: deployDev + jobs: + - template: deploy.yml@templates + parameters: + environment: 'dev' +- stage: deployProd + jobs: + - template: deploy.yml@templates + parameters: + environment: 'prod' +``` + +However, there is nothing here that *enforces* a developer to use these templates - they could either write their own or just create their pipeline inline. This is where [Extends](https://learn.microsoft.com/en-us/azure/devops/pipelines/security/templates?view=azure-devops#use-extends-templates) comes into play! + +I remember when this was first announced in a [sprint release note](https://docs.microsoft.com/en-us/azure/devops/release-notes/2019/sprint-162-update#use-extends-keyword-in-pipelines) in December 2019, we tried it and couldn't really get it to work the way we wanted to. But with the client's requirements, this seemed like a perfect time to give it another shot. I wanted to reference a separate configuration repository where the secret scanning config would be stored without the developer having to worry or care about it, and we found a way to do just that using Extends. + +## The Code + +In this demo scenario, my code is stored in GitHub, but this could work just as well with code in Azure Repos as well. + +### [`azure-pipelines.yml`{: .filepath}](https://github.com/joshjohanning/secrets-scanning-poc/blob/f07a2eae415f38933f506d4ed0c69f75df2ffb91/azure-pipelines.yml){: .filepath} + +In the root `azure-pipelines.yml`{: .filepath} file, you'll notice that the `extends` keyword is at the same level as `trigger` and `resources`. This was the tricky part - how does one use `extends` AND job templates? The approach is to use a *steps* template for the build where we want the extra steps injected, and for deployment we can use our *job* templates like normal. We will add an Environment check that ensures that the extends template is being used. If the Extends template isn't used, the check fails and the deployment isn't allowed. + +The deployment stages and jobs are defined in this file as well - this should look very familiar to regular deployment jobs except that they are being referenced as a parameter. + +{% raw %} + +```yaml +trigger: + - main + +resources: + repositories: + - repository: templates + type: github + name: joshjohanning/pipeline-templates + endpoint: joshjohanning + +extends: + template: secret-scanning/secret-scanning-extends.yml@templates + parameters: + buildSteps: + # use steps template for build + - template: secret-scanning/sample-build-steps.yml@templates + parameters: + whatToBuild: 'Hello world' + deployStages: + - stage: dev + displayName: deploy to dev + jobs: + # use job templates as normal for deployment + # bug using github as template repo?: must use ../ + - template: ../secret-scanning/sample-deployment-job.yml@templates + parameters: + environment: github-secret-scanning-test-gate-dev + - stage: prod + displayName: deploy to prod + jobs: + - template: ../secret-scanning/sample-deployment-job.yml@templates + parameters: + environment: github-secret-scanning-test-gate-prod +``` + +### [`secret-scanning-extends.yml`{: .filepath}](https://github.com/joshjohanning/pipeline-templates/blob/main/secret-scanning/secret-scanning-extends.yml) + +The `parameters` passed into the extends template include a `stepList` type for the `buildSteps` and a `stageList` for the `deployStages`. + +The `resource` and `- template: secret-scanning-steps.yml`{: .filepath} here is the configuration repository I was mentioning before. For your use case, you may not need this, you would just need the steps in `- ${{ parameters.buildSteps }}`. + +The build stage and job is defined in this file. + +```yaml +parameters: +- name: buildSteps # the name of the parameter is buildSteps + type: stepList # data type is StepList + default: [] # default value of buildSteps +- name: deployStages + type: stageList + default: [] + +resources: + repositories: + - repository: secretscanning + type: github + name: joshjohanning/secret-scanning-config + endpoint: joshjohanning + +stages: +- stage: secure_buildstage + displayName: 'Secure Build Stage' + jobs: + - job: secure_buildjob + steps: + + - template: secret-scanning-steps.yml + + - ${{ parameters.buildSteps }} + +- ${{ parameters.deployStages }} +``` + +### [`sample-build-steps.yml`{: .filepath}](https://github.com/joshjohanning/pipeline-templates/blob/main/secret-scanning/sample-build-steps.yml) + +Not much crazy here - this is a *steps* template (as opposed to a *job* template). This is injected into the extends template in the `- ${{ parameters.buildSteps }}` line of code. + +```yaml +parameters: + whatToBuild: '' + +steps: +- script: | + echo "${{ parameters.whatToBuild }}, here is where I do my build!" + +``` + +### [`sample-deployment-job.yml`{: .filepath}](https://github.com/joshjohanning/pipeline-templates/blob/main/secret-scanning/sample-deployment-job.yml) + +This is pretty vanilla as well - this *job* template is injected into the extends template in the `- ${{ parameters.deployStages }}` line of code. + +```yaml +parameters: + environment: '' + pool: + vmImage: 'ubuntu-latest' +jobs: +- deployment: deploy + displayName: deploy + pool: ${{ parameters.pool }} + environment: ${{ parameters.environment }} + strategy: + runOnce: + deploy: + steps: + - script: | + echo "deploy hello world" + displayName: deploy +``` + +## Configuration in Azure DevOps + +The **Required YAML Template** check is added to the environment just as an Approval would be: + +![Azure DevOps required check](/assets/screenshots/2020-12-08-extends-template/required-check.png){: .shadow } +_Adding the required template check to an environment_ + +*Note here if you are storing the code in Azure Repos - the example in this screenshot mentions `project/repository-name`. If the repository is in the same project, DO NOT include the project name in the path otherwise it won't work.* + +Now, if you try to deploy to an environment while not using this extends template, it fails: +![failed stage](/assets/screenshots/2020-12-08-extends-template/failed-stage.png){: .shadow } +_Fails the required template check_ + +If you click the 0/1 checks passed, it shows the check that failed and hyperlinks to the checks for that environment: +![failed check](/assets/screenshots/2020-12-08-extends-template/failed-check.png){: .shadow } +_More details on the failed required template check_ + +Once you properly use the extends template - success! +![successful stage](/assets/screenshots/2020-12-08-extends-template/successful-stage.png){: .shadow } +_Passes the required template check_ + +## Conclusion and Next Steps + +This 'Required Checks' concept works really well for environments that are defined ahead of time as a way to manage logical groupings of like deployments and add manual approval points. + +Did you know that you can also add these same type of required checks on Service Connections? Yep! Therefore, you can configure your production Azure Service Connection such that ONLY certain users can make the approval, irregardless of how the environment and pipeline is set up. + +Completing this circle, we can ensure that protected resources in Azure DevOps - environments AND service connections - extend from a particular template, ensuring compliance and standardization across the organization. + +Happy templating! + +## Updates - Jan 5 2020 - Using Build Job template instead of Build Steps template + +After using the template for a few weeks, I've made an update to be able to pass in *build jobs* instead of *build steps*. Note that with this template, you can pass in either. I prefer using a separate job under the build stage for the secret scanning bit so I can see what failed - the secret scan or the build. + +I have an input parameter for `buildStageName` so that this would still make sense in a scenario where there isn't a stage named Build, such as in Terraform deployments. By default, the stage will be named build, but it can be optionally overridden (called something like `secret-scanning` instead, in the Terraform example). + +**secret-scanning-extends.yml:** + +```yaml +parameters: +- name: buildSteps # the name of the parameter is buildSteps + type: stepList # data type is StepList + default: [] # default value of buildSteps +- name: buildJobs # the name of the parameter is buildSteps + type: jobList # data type is StepList + default: [] # default value of buildSteps +- name: deployStages + type: stageList + default: [] +- name: 'buildStageName' + type: string + default: 'build' + +resources: + repositories: + - repository: secretscanning + type: github + name: joshjohanning/secret-scanning-config + endpoint: joshjohanning + +stages: +- stage: ${{ parameters.buildStageName }} + displayName: ${{ parameters.buildStageName }} + jobs: + - job: secret_scanning + steps: + + - template: secret-scanning/secret-scanning-steps.yml + + - ${{ parameters.buildSteps }} + + - ${{ parameters.buildJobs }} + +- ${{ parameters.deployStages }} + +``` + +**azure-pipelines.yml:** + +```yaml +trigger: + - main + +resources: + repositories: + - repository: templates + type: github + name: joshjohanning/pipeline-templates + endpoint: joshjohanning + +extends: + template: secret-scanning/secret-scanning-extends.yml@templates + parameters: + buildJobs: + # job template + - template: my-build-job.yml@templates + deployStages: + - stage: dev + displayName: deploy to dev + jobs: + # job template + - template: secret-scanning/sample-deployment-job.yml@templates + parameters: + environment: github-secret-scanning-test-gate-dev + - stage: prod + displayName: deploy to prod + jobs: + - template: secret-scanning/sample-deployment-job.yml@templates + parameters: + environment: github-secret-scanning-test-gate-prod +``` + +{% endraw %} diff --git a/_posts/2020-12-16-github-codeql-pr.md b/_posts/2020-12-16-github-codeql-pr.md new file mode 100644 index 0000000..a1f64c8 --- /dev/null +++ b/_posts/2020-12-16-github-codeql-pr.md @@ -0,0 +1,196 @@ +--- +title: 'GitHub: Block Pull Request if Code Scanning Alerts Are Found' +author: Josh Johanning +date: 2020-12-16 20:00:00 -0600 +categories: [GitHub, Advanced Security] +tags: [GitHub, GitHub Actions, Pull Requests, CodeQL, GitHub Advanced Security, Policy Enforcement, Branch Protection Rules] +image: + path: /assets/screenshots/2020-12-16-github-codeql-pr/pr.png + width: 918 # in pixels + height: 390 # in pixels + alt: Pull Request that is blocked because Code Scanning Alerts are found +--- + +## Overview + +After virtually attending GitHub Universe last week and watching the [GitHub Advanced Security round-up](https://www.youtube.com/watch?v=T_-Tn81b4lc) and [Catching vulnerabilities early with GitHub](https://www.youtube.com/watch?v=l2epzyytPGE) sessions, it got me thinking: How do I block a pull request from being merged if the scans detect issues? I didn't think the [GitHub Docs](https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/enabling-code-scanning-for-a-repository#understanding-the-pull-request-checks) were incredibly straight forward on how this works. + +I knew how to configure a branch protection rule in GitHub that enforces things such as a GitHub Action or Azure DevOps stage completes successfully, but what about code scanning? How configurable is it? + +## How To + +If you already have a Code Scanning workflow configured, skip to step #7. + +1. The first thing you need is a public repository (GHAS is free for public repos) or a private repository with the GitHub Advanced Security license enabled +1. In the Security tab on the repository, navigate to 'Code scanning alerts' page +1. I'm using the native 'CodeQL Analysis' workflow by GitHub - there are 3rd party analysis engines here too! +1. Take a look at the workflow file - I didn't need to make any changes, but one can modify the language matrix if you want/don't want scans to run for a particular language +1. There's an `Autobuild` step here that works most of the time, but for some repositories I found I had to build my app manually - further reading on [build steps for compiled languages](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-the-codeql-workflow-for-compiled-languages#adding-build-steps-for-a-compiled-language) +1. Commit the workflow to your branch and merge it into Main - for best results we want an initial scan in the default branch before we test out the PR process +1. Under the Settings tab in the repository, navigate to Branches +1. Create a [branch protection rule](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule#creating-a-branch-protection-rule) for your default branch - check the 'Require status checks to pass before merging' box +1. If you used the GitHub CodeQL workflow, add the `CodeQL` status check and save the rule + - You don't want the `Analyze (javascript)` status check; that just will show if the particular scan has completed, not that it found any vulnerabilities + - If you don't see the `CodeQL` to add as a status check to the branch protection, **it won't appear as an option until you initiate at least one PR on the repository that triggers and completes the entire CodeQL scan** (meaning all of the `Analyze` jobs have finished) - as of December 2021, this is still an issue. It is vaguely alluded to in this [tidbit in the documentation](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/setting-up-code-scanning-for-a-repository#understanding-the-pull-request-checks) - emphasis mine: + > - When the code scanning jobs complete, GitHub works out whether any alerts were added by the pull request and adds the "Code scanning results / TOOL NAME" entry to the list of checks. **After code scanning has been performed at least once**, you can click Details to view the results of the analysis. + +Step #9 was the part I wasn't originally confused on. The other entry (eg. `Analyze (javascript)`) is only the *scan job* for the corresponding language. It should succeed irregardless of if vulnerabilities are found. If it fails, the `autobuild` task might not be able to compile your code. The [Understanding the pull request checks +](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/setting-up-code-scanning-for-a-repository#understanding-the-pull-request-checks) GitHub documentation summarizes well. + +After configuring the code scanning workflow and branch protection policy, you should be all set! + +![branch protection policy configuration](/assets/screenshots/2020-12-16-github-codeql-pr/branch-protection-configuration.png){: .shadow } +_Branch Protection Policy with the CodeQL status check configured_ + +## Testing It Out + +Another thing the GitHub Docs do not do a good job of spelling out is that only *Errors* or a security severity level of *High or Higher* are going to fail the Pull Request status check. *Warnings*, out of the box, do not block the PR. + +Alright, so let's introduce an error...does anyone know of an easy vulnerability we can put in our code? Well neither do I, but we don't have to with the help of our friend, the [Semmle vulnerability database](https://web.archive.org/web/20200929073843/https://help.semmle.com/wiki/label/js/path-problem) (Note: This is now the [GitHub Advisory Database](https://github.com/advisories)). + +I'm going to use an incorrect suffix vulnerability. The easiest way to introduce this is to: 1) make sure `javascript` is in the language matrix in our CodeQL workflow like so: `language: [ 'csharp', 'javascript' ]` and 2) check in a simple `.js`{: .filepath} file somewhere in the repository with the bad code: + +```javascript +function endsWith(x,y) { + + let index = x.lastIndexOf(y); + return x.lastIndexOf(y) === x.length - y.length; + +} +``` + +Make sure to commit this in a branch because we want to test out the PR flow! + +Afterwards, create the PR and wait for the job to run: + +![blocked pr](/assets/screenshots/2020-12-16-github-codeql-pr/pr.png){: .shadow } +_Pull Request that is blocked because of a code scanning vulnerability (note that I can still force merge since I am an Administrator)_ + +Success! Or, failure, just as we wanted - the pull request cannot be merged because the `CodeQL` status check failed, meaning it detected a vulnerability in the code. + +Once you fix the vulnerable code and re-push, all of the status checks will be successful and you are free to merge: + +![passing pr](/assets/screenshots/2020-12-16-github-codeql-pr/pr-passing.png){: .shadow } +_Pull Request with passing status checks - no vulnerable code has been found_ + +The fact that GitHub gives this away for free for all public repositories is incredible! There is a licensing upcharge for Enterprises, but the setup is so simple and integrations so robust makes it well worth it (and we're only scratching the surface!). + +## Status Check Failure Configuration + +By default, the status check will only fail if there is an *Error* or a security severity level of *High or Higher* *High or Higher* vulnerability - *Warnings* or a security level of *Medium* will not fail the status check. However, we can use the [Control which code scanning alerts cause a pull request check to fail](https://github.blog/changelog/2021-06-03-control-which-code-scanning-alerts-cause-a-pull-request-check-to-fail/) feature that was release in June 2021 to configure what alert level will fail the PR: + +![defining-the-severities-causing-pull-request-check-failure](/assets/screenshots/2020-12-16-github-codeql-pr/code-scanning-configuration.png){: .shadow } +_Control which code scanning alerts cause a pull request check to fail_ + +For more information, see the documentation for [Defining the severities causing pull request check failure](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#defining-the-severities-causing-pull-request-check-failure). + +## Summary + +Not allowing code that introduces vulnerabilities to be merged in the PR process is crucial to ensuring the integrity of our code. Blocking a PR that contains a code vulnerability is essentially THE use case of GitHub Advanced Security - we're able to see right on our PR in GitHub that there's a vulnerability, with the deep linking and integration that you would expect. Finding out about the issue at PR time shortens the feedback loop - we don't have to scramble before a production deployment if your security scan is occurring too late in the process. + +Even without the branch protection configured, the code scanning results will still show that `CodeQL` found a vulnerability, but without the branch protection we would be able to freely merge this into main: +![pr with no branch protection](/assets/screenshots/2020-12-16-github-codeql-pr/pr-no-branch-protection.png){: .shadow } +_Pull Request that shows a code vulnerability found, but since there is no branch protection on this repo, we are free to merge_ + +This might be enough for some, but if we're going to go through the exercise of creating a code scanning workflow, just as it's a best practice to require at least one other approver on the PR before merging, we should require that there are no vulnerabilities being introduced as well. GitHub Advanced Security prides itself on limiting the signal vs noise ratio, so the chance of getting a 'High' vulnerability result that is a false positive is pretty minimal. And if you do get a false positive or a result you aren't going to fix - just [dismiss](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/managing-code-scanning-alerts-for-your-repository#dismissing-or-deleting-alerts) the alert. + +Happy (secure) coding! + +--- + +## Extra Credit - Analyzing a SARIF File + +I originally had this section in here to assist with blocking PR's from results other than *Errors* (such as *Warnings*), but GitHub has [implemented this feature now](#status-check-failure-configuration). I will leave the section below as it might be useful in its own right if you are interested in deep-diving or debugging your `SARIF` results file. + +### Original Content - Analyzing a SARIF Results File Manually + +Okay, what if we were to have a repository with Terraform code and used the ShiftLeft Analysis marketplace code scanning workflow? Or, we used the native GitHub CodeQL workflow but want it to block merges when it finds any result, including warnings? + +Well, in the case of the ShiftLeft Analysis workflow, there is a [config file](https://web.archive.org/web/20210615164536/https://slscan.io/en/latest/integrations/tips/#config-file) that can be uploaded to the root of the repository to define some of this, but I haven't played around much for this. For the GitHub CodeQL workflow, there is no fine-tuning configuration file that we can easily use (that I know of at this date). + +For this, I wrote a script that examines the `.sarif`{: .filepath} and added another job to the workflow, like so: + +{% raw %} + +```yaml + Detect-Errors: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [ 'csharp', 'javascript' ] + needs: + - analyze + steps: + - name: Download Sarif Report + uses: actions/download-artifact@v2 + with: + name: sarif-report + + - name: Detect Errors + run: | + repo=$(echo ${{ github.repository }} | awk -F'/' '{print $2}') + results=$(cat $repo/results/${{ matrix.language }}-builtin.sarif | jq -r '.runs[].results[].ruleId') + + resultsArray=($results) + + echo "${resultsArray[*]}" + + errorCount=0 + warningCount=0 + noteCount=0 + + for var in "${resultsArray[@]}" + do + severity=$(cat $repo/results/${{ matrix.language }}-builtin.sarif | jq -r '.runs[].tool.driver.rules[] | select(.id=="'$var'").properties."problem.severity"') + echo "${var} | $severity" + if [ "$severity" == "warning" ]; then let warningCount+=1; fi + if [ "$severity" == "error" ]; then let errorount+=1; fi + if [ "$severity" == "note" ]; then let noteount+=1; fi + done + + echo "" + echo "Error Count: $errorCount" + echo "Warning Count: $warningCount" + echo "Note Count: $noteCount" + echo "" + + if (( $errorCount > 0 )); then + echo "errors found - failing detect error check..." + exit -1 + fi + + if (( $warningCount > 0 )); then + echo "warnings found - failing detect warning check..." + exit -1 + fi +``` + +This will fail if any findings were found, including warnings - modify the script as needed. + +Note that we also have to add an `Upload Build Artifact` step in the `Analyze` job, like so: + +```yaml + - name: Upload Sarif Report to Workflow + uses: actions/upload-artifact@v2 + with: + name: sarif-report-${{ matrix.language }} + path: /home/runner/work/**/*.sarif +``` + +{% endraw %} + +Depending on the workflow, you may have to modify the `path` in the Upload task as well as the script. You can find out the relative path of the .sarif report by viewing the Actions' logs. + +The entire workflow can be found in my [GitHub branch](https://github.com/joshjohanning/tailspin-spacegame-web-deploy/blob/2d4955b668ffde45a2f4ea6e742268a536249b27/.github/workflows/codeql-analysis.yml). + +Because the `.sarif`{: .filepath} produced by the ShiftLeft analysis is slightly different and by default *doesn't* fail the job even with *errors*, I created a different workflow you can use to block pull requests if errors or warnings are found - see for [example](https://github.com/joshjohanning/azdo-terraform-tailspin/blob/05151b64818db1c4cabf5aaf51f0024c779d81f5/.github/workflows/shiftleft-analysis.yml). + +Now just like we did above, we can modify our branch rule to require the "Detect-Errors" job to finish successfully, as this job will run successfully if there are no errors/warnings. + +![pr-detected-errors-job](/assets/screenshots/2020-12-16-github-codeql-pr/pr-detected-errors.png){: .shadow } +_Adding the new job to the required status check list_ +![pr-blocked](/assets/screenshots/2020-12-16-github-codeql-pr/pr-blocked.png){: .shadow } +_Pull Request that is blocked because of a 'warning' result found in the code scanning results_ + +I'm sure there is probably a better way to do this (using the API or GraphQL endpoint?). I know back in the LGTM / Semmle days, there was also a config file you could commit to the root of the repository to more precisely define rules. Either way, let me know in the comments if you have any other ideas or improvements! diff --git a/_posts/2020-12-20-nuget-pusher-script.md b/_posts/2020-12-20-nuget-pusher-script.md new file mode 100644 index 0000000..717426e --- /dev/null +++ b/_posts/2020-12-20-nuget-pusher-script.md @@ -0,0 +1,120 @@ +--- +title: 'Quickly Migrate NuGet Packages to a New Feed' +author: Josh Johanning +date: 2020-12-23 16:45:00 -0600 +categories: [Azure DevOps, Artifacts] +tags: [Azure DevOps, NuGet, Scripts, Migrations] +media_subpath: /assets/screenshots/2020-12-20-nuget-pusher-script +image: + path: azure-artifacts.png + width: 100% + height: 100% + alt: NuGet packages in Azure Artifacts +--- + +## Summary + +This is a *very* simple bash script that can assist you in migrating NuGet packages to a different Artifact feed. It's written with Azure DevOps in mind as a target, but there's no reason why you couldn't use any other artifact feed as a destination. + +I used the script after I ran a NuGet restore locally of a Visual Studio solution, found my `.NuGet`{: .filepath} folder with all of the cached packages, placed my script in that folder, and ran it. This saved me a lot of time, migrating 102 packages while I grabbed a cup of coffee ☕️! + +> Note that this won't work for migrating NuGet packages to **GitHub Packages** since the `` element in the `.nuspec`{: .filepath} file in the `.nupkg`{: .filepath} needs to be updated. See my other posts: +> - [Migrate NuGet Packages Between GitHub Instances](/posts/github-packages-migrate-nuget-packages/) +> - [Migrate NuGet Packages to GitHub Packages](/posts/github-packages-migrate-nuget-packages-to-github-packages/) +{: .prompt-info } + +## The Script + +Copy the script below and save it as `nuget-pusher.sh`{: .filepath} in the folder where your `.nupkg`{: .filepath} files are located. Don't forget to `chmod +x nuget-pusher.sh`{: .filepath} to make it executable! + +{% raw %} + +```bash +#!/bin/bash + +# Usage: ./nuget-pusher-script.sh + +if [ -z "$3" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "..." + +NUGET_FEED_NAME=$1 +NUGET_FEED_SOURCE=$2 +PAT=$3 + +# adding to ~/.config/NuGet/NuGet.config +dotnet nuget add source \ + $NUGET_FEED_SOURCE \ + --name $NUGET_FEED_NAME \ + --username "az" \ + --password $PAT \ + --store-password-in-clear-text + +results=$(find . -name "*.nupkg") +resultsArray=($results) + +for pkg in "${resultsArray[@]}" +do + echo $pkg + dotnet nuget push --source \ + $NUGET_FEED_NAME \ + --api-key az \ + $pkg +done + +# clean up +dotnet nuget remove source $NUGET_FEED_NAME + +echo "..." +``` + +{% endraw %} + +> According to the [docs](https://learn.microsoft.com/en-us/azure/devops/artifacts/nuget/dotnet-exe?view=azure-devops#publish-packages), any string will work for the `--api-key` parameter. +{: .prompt-info } + +## Running the Script + +The script below can be called via: + +```bash +./nuget-pusher-script.sh +``` + +An example: + +```bash +./nuget-pusher-script.sh \ + azure-devops \ + https://pkgs.dev.azure.com/jjohanning0798/_packaging/my-nuget-feed/nuget/v3/index.json \ + xyz_my_pat +``` + +## Bonus: Locating .nupkg Packages + +How to find the location of `.nupkg`{: .filepath} files: + +```bash +find / -name "*.nupkg" 2> /dev/null +``` + +How to find the location of `.nupkg`{: .filepath} files and copy them all to a directory: + +```bash +find / -name "*.nupkg" -exec cp "{}" ./my-directory \; 2> /dev/null +``` + +> The `2> /dev/null` is to suppress permission errors. +{: .prompt-info } + +## Improvement Ideas + +* [ ] The `username` doesn't matter in this script since when using an Azure DevOps PAT, username is irrelevant. If one was pushing to a NuGet feed that required username authentication, I would presume you would add that as an input. +* [ ] One could also add a source folder as an input too instead of relying on current directory +* [x] Also one could use a more elaborate input mechanism to the script... +* [x] Use `dotnet nuget` instead of `nuget` command +* [x] Support other systems (such as `ubuntu`) with `--store-password-in-clear-text` flag +* [x] Clean up temporary NuGet source when done diff --git a/_posts/2021-01-04-authorize-azure-artifacts-in-github-actions.md b/_posts/2021-01-04-authorize-azure-artifacts-in-github-actions.md new file mode 100644 index 0000000..0d74501 --- /dev/null +++ b/_posts/2021-01-04-authorize-azure-artifacts-in-github-actions.md @@ -0,0 +1,196 @@ +--- +title: 'Authorize and Restore Azure Artifacts NuGet Packages in GitHub Actions' +author: Josh Johanning +date: 2021-01-04 14:15:00 -0600 +description: Authenticate to Azure Artifacts from GitHub Actions for builds and code scanning workflows +categories: [GitHub, Actions] +tags: [Azure DevOps, NuGet, GitHub, GitHub Actions, Azure Artifacts, Artifactory, CodeQL] +--- + +## Summary + +While this post is geared towards Azure DevOps and Azure Artifacts, this approach will work for any third-party feed that requires authentication (like [Artifactory](#artifactory)!). + +I needed to be able to restore my NuGet packages hosted in an Azure Artifacts instance in a GitHub Action workflow. In Azure Pipelines, it's relatively simple with the [Restore NuGet Packages](https://docs.microsoft.com/en-us/azure/devops/pipelines/packages/nuget-restore?view=azure-devops) task. This task dynamically creates a NuGet config with the proper authentication details to Azure Artifacts. In GitHub Actions, there isn't a native action readily available for us to accomplish this. + +I tried the [shubham90-nugetauth](https://github.com/marketplace/actions/shubham90-nugetauth) marketplace action, but I couldn't get it to work. The inputs only called for the `azure-devops-org-url`, not a particular Artifact feed, so I wasn't sure how it sets up the configuration for the feeds since an organization could have multiple NuGet feeds. There is another action that looks promising, [GitHub NuGet Private Source Authorisation](https://github.com/marketplace/actions/github-nuget-private-source-authorisation), but I decided to use the command line for increased flexibility. + +What I did instead was borrow some of my scripting knowledge from my [NuGet Pusher](/posts/nuget-pusher-script/) post to programmatically add my Azure Artifacts feed as a source (with credentials) and restore the solution. This is summarized with a few simple commands: + +{% raw %} + +```yaml +- name: Auth NuGet + run: | + dotnet nuget add source ${{ env.nuget_feed_source }} \ + --name ${{ env.nuget_feed_name }} \ + --username az \ + --password ${{ secrets.AZURE_DEVOPS_TOKEN }} \ + --configfile ${{ env.nuget_config }} \ # required if have config file + --store-password-in-clear-text # required if using Linux + +- name: Restore NuGet Packages + run: dotnet restore ${{ env.solution_file_path }} +``` + +Notes: +- This should work with either .NET Core as well as full .NET Framework on both Linux and Windows +- On Linux runners, you need to use `--store-password-in-clear-text`{: .code} - not required on Windows +- The `--configfile` argument is optional - if not specified, there is a [hierarchy involved](https://docs.microsoft.com/en-us/nuget/consume-packages/configuring-nuget-behavior): + 1. It will first try to use the `NuGet.config`{: .filepath} in the current working directory first + 2. Next, it will use the local user `NuGet.config`{: .filepath} in `%appdata%\NuGet\NuGet.Config`{: .filepath} (Windows) or `~/.nuget/NuGet.Config`{: .filepath} (Linux/Mac, depending on distro) + 3. Note: You cannot add a source to the `NuGet.config`{: .filepath} with a name that already exists - either add the source with a new source name or run [`dotnet nuget remove source`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-nuget-remove-source) to remove the source first +- If the `.sln`{: .filepath} is in the root (or current working directory), you can simply run `dotnet restore` without the solution path as well +- Reference the [`dotnet nuget add source`](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-nuget-add-source) and [`dotnet restore`](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-restore) docs for more information + +## Setup + +Let's take a step back and add some things that are necessary to make this work. + +1. First, we have to [generate an Azure DevOp Personal Access Token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows#create-a-pat) to +1. Next, we have to [create a secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) either at the repository or GitHub organization with an Azure DevOps PAT that has access to the Artifact feed. I called my secret: `AZURE_DEVOPS_TOKEN` +2. For reusability and ease, let's add in a few environment variables to the GitHub Action workflow: + +```yaml +env: + solution_file: 'My.Core.App.sln' + nuget_feed_name: 'My-Azure-Artifacts-Feed' + nuget_feed_source: 'https://pkgs.dev.azure.com//_packaging//nuget/v3/index.json' + nuget_config: '.nuget/NuGet.Config' +``` + +Note that my Azure Artifacts feed was scoped to the Organization level, the NuGet Feed Source url will be slightly different depending on if you used a Project feed. The source URL can be found by navigating to the Azure Artifacts feed and clicking the **Connect to Feed** button. + +## Complete Workflow + +Including the complete code scanning workflow for reference - the only bit custom here is the environment variables and the Auth NuGet and Restore NuGet Packages run commands: + +```yaml +name: "CodeQL" + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: '19 0 * * 2' + workflow_dispatch: # manual trigger + +env: + solution_file: 'My.Core.App.sln' + nuget_feed_name: 'My-Azure-Artifacts-Feed' + nuget_feed_source: 'https://pkgs.dev.azure.com//_packaging//nuget/v3/index.json' + nuget_config: '.nuget/NuGet.Config' + +jobs: + analyze: + name: Analyze + runs-on: windows-latest + + strategy: + fail-fast: false + matrix: + language: [ 'csharp' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + # # If exists, remove existing AzDO NuGet source that doesn't have authentication + # - name: Remove existing entry from NuGet config + # run: | + # dotnet nuget remove source ${{ env.nuget_feed_name }} \ + # --configfile ${{ env.nuget_config }} + + - name: Auth NuGet + run: | + dotnet nuget add source ${{ env.nuget_feed_source }} \ + --name ${{ env.nuget_feed_name }} \ + --username az \ + --password ${{ secrets.AZURE_DEVOPS_TOKEN }} \ + --configfile ${{ env.nuget_config }} + + - name: Restore NuGet Packages + run: dotnet restore ${{ env.solution_file_path }} + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 +``` +{: file='.github/workflows/codeql-analysis.yml'} + +If I was running on `ubuntu-latest`, the only change would be to add the `--store-password-in-clear-text`argument: + +```yml + - name: Auth NuGet + run: | + dotnet nuget add source ${{ env.nuget_feed_source }} \ + --name ${{ env.nuget_feed_name }} \ + --username az --password ${{ secrets.AZURE_DEVOPS_TOKEN }} \ + --configfile ${{ env.nuget_config }} \ + --store-password-in-clear-text +``` +{: file='.github/workflows/codeql-analysis.yml'} + +## When would you have to use the dotnet sources remove command? + +You may have noticed a commented-out run command in the above workflow: + +```yaml +# If exists, remove existing AzDO NuGet source that doesn't have authentication +- name: Remove existing entry from NuGet config + run: | + dotnet nuget remove source ${{ env.nuget_feed_name }} \ + --configfile ${{ env.nuget_config }} +``` +{: file='.github/workflows/codeql-analysis.yml'} + +If your `NuGet.config`{: .filepath} already has an Azure DevOps entry, you will need to remove it with [dotnet nuget remove source](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-nuget-remove-source) otherwise you will likely see `401 Unauthorized` errors during the `dotnet restore`. This is because that entry doesn't (or at least shouldn't!) have any credentials associated with it committed into source control, so it essentially tries to access it anonymously and will fail. + +Also, we have to remove it because we cannot add a sources entry to the `NuGet.config`{: .filepath} with the same name. + +## Improvement Ideas / Notes + +1. If your solution does not contain a `NuGet.config`{: .filepath} file, you may have to create a temporary config file similar to how the [NuGet Command task](https://github.com/microsoft/azure-pipelines-tasks/blob/master/Tasks/NuGetCommandV2/nugetrestore.ts#L136) works in Azure DevOps + - Alternatively, simply omitting the `--configfile` argument will use the [local user](https://docs.microsoft.com/en-us/nuget/consume-packages/configuring-nuget-behavior) `NuGet.config`{: .filepath} + - Using the local `NuGet.config`{: .filepath} will certainly work with GitHub-hosted runners since it's a fresh instance each time, but you may run into conflicts if you're on a shared self-hosted runner + - This [marketplace action](https://github.com/marketplace/actions/github-nuget-private-source-authorisation) uses a [local `NuGet.config`{: .filepath}](https://github.com/StirlingLabs/GithubNugetAuthAction/blob/main/action.sh#L25:L34) by default +1. The `Restore NuGet Packages` command might not be needed since the `Autobuild` action performs a restore as well - therefore one may also be able to remove the `solution_file` variable - but I always like to have an explicit task for restoring packages so I know exactly if that failed +2. If you the `Autobuild` Action does not successfully build your project for code scanning, you will have to build it manually. Using full .NET Framework, there is an additional action that you need to add to add MSBuild to the path ([`microsoft/setup-msbuild@v1`](https://github.com/marketplace/actions/setup-msbuild)). Here's an example: + +```yaml +- name: Add msbuild to PATH + uses: microsoft/setup-msbuild@v1 + +- name: MSBuild Solution + run: msbuild ${{ env.solution_file }} /p:Configuration=release +``` + +## Artifactory + +I've seen a few instances where a team is using an [API key to access Artifactory](https://www.jfrog.com/confluence/display/JFROG/NuGet+Repositories#NuGetRepositories-NuGetAPIKeyAuthentication), so the command is slightly different: + +```yaml +- name: Auth NuGet + run: nuget setapikey admin:${{ secrets.API_KEY }} -Source Artifactory + +- name: Restore NuGet Packages + run: dotnet restore ${{ env.solution_file_path }} +``` + +Notes: +- The `-ConfigFile` argument can optionally be used to specify a `NuGet.config`{: .filepath} file +- Reference the [`nuget setapikey`](https://docs.microsoft.com/en-us/nuget/reference/cli-reference/cli-ref-setapikey) docs for more information + +{% endraw %} diff --git a/_posts/2021-01-17-reparent-work-items.md b/_posts/2021-01-17-reparent-work-items.md new file mode 100644 index 0000000..fc21d76 --- /dev/null +++ b/_posts/2021-01-17-reparent-work-items.md @@ -0,0 +1,76 @@ +--- +title: 'Azure DevOps: Bulk Reparent Work Items' +author: Josh Johanning +date: 2021-01-17 16:30:00 -0600 +categories: [Azure DevOps, Work Items] +tags: [Azure DevOps, Work Items, Scripts] +--- + +## Reparent Work Items in the UI + +If you are reparenting only a few work items, then the easiest way is to use the Mapping view in the Azure DevOps backlog, as described by [Microsoft](https://docs.microsoft.com/en-us/azure/devops/boards/backlogs/organize-backlog?view=azure-devops#map-items-to-group-them-under-a-feature-or-epic): + +![turn-mapping-on](https://docs.microsoft.com/en-us/azure/devops/boards/backlogs/media/organize-backlog/turn-mapping-on-agile.png){: .shadow } +_Enabling the mapping pane_ +![map-workitems](https://docs.microsoft.com/en-us/azure/devops/boards/backlogs/media/organize-backlog/map-unparented-items-agile.png){: .shadow } +_Reparenting work items in Azure DevOps with the mapping pane_ + +## Reparent Work Items with a Script + +However, mapping (or reparenting) work items in the Azure DevOps UI *can* be a little clunky - it can be done in mass using the parent mapping pane, but what if you have hundreds or thousands of work items split across multiple parent/child relationships or multiple backlogs? Then it becomes harder since you can't use this functionality in a query window, only from the backlog. + +This is a *very* simple PowerShell script utilizing the [Azure DevOps CLI extension](https://docs.microsoft.com/en-us/azure/devops/cli/?view=azure-devops) that can very quickly update the parent on a query of work items. I used a combination of the CLI commands with PowerShell, since PowerShell makes it super simple to use loops and JSON parsing. Before the Azure DevOps CLI, this would have to have been done with using the [APIs](https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/?view=azure-devops-rest-6.0), which isn't hard, but this solution uses way fewer lines of code! + +In this example, I am using a Tag on the work items I want to update. + +{% raw %} + +```powershell +############################### +# Reparent work item +############################### + +# Prerequisites: +# az devops login (then paste in PAT when prompted) + +[CmdletBinding()] +param ( + [string]$org, # Azure devops org without the URL, eg: "MyAzureDevOpsOrg" + [string]$project, # Team project name that contains the work items, eg: "TailWindTraders" + [string]$tag, # only one tag is supported, would have to add another clause in the $wiql, eg: "Reparent" + [string]$newParentId # the ID of the new work item, eg: "223" +) + +az devops configure --defaults organization="https://dev.azure.com/$org" project="$project" + +$wiql="select [ID], [Title] from workitems where [Tags] CONTAINS '$tag' order by [ID]" + +$query=az boards query --wiql $wiql | ConvertFrom-Json + +ForEach($workitem in $query) { + $links=az boards work-item relation show --id $workitem.id | ConvertFrom-Json + ForEach($link in $links.relations) { + if($link.rel -eq "Parent") { + $parentId=$link.url.Split("/")[-1] + if($parentId -ne $newParentId) { + write-host "Unparenting" $links.id "from $parentId" + az boards work-item relation remove --id $links.id --relation-type "parent" --target-id $parentId --yes + + write-host "Parenting" $links.id "to $newParentId" + az boards work-item relation add --id $links.id --relation-type "parent" --target-id $newParentId + } + else { + write-host "Work item" $links.id "is already parented to $parentId" + } + } + } +} +``` + +{% endraw %} + +### Improvement Ideas + +* Utilize the [environment variable or from file method](https://docs.microsoft.com/en-us/azure/devops/cli/log-in-via-pat?view=azure-devops&tabs=windows) to be able to run `az devops login` in an unattended fashion +* Use the APIs if you are so inclined, but I still like to use the CLI when possible +* Expand the WIQL, and maybe add it as a script parameter! diff --git a/_posts/2021-01-25-trac-to-github.md b/_posts/2021-01-25-trac-to-github.md new file mode 100644 index 0000000..e55d93c --- /dev/null +++ b/_posts/2021-01-25-trac-to-github.md @@ -0,0 +1,165 @@ +--- +title: 'So You Want to Migrate Trac Tickets to GitHub Issues' +author: Josh Johanning +date: 2021-01-25 8:30:00 -0600 +categories: [GitHub, Migrations] +tags: [GitHub, GitHub Issues, Migrations] +--- + +## Disclaimer + +I should first off state that I wouldn't entirely recommend this, if you are migrating to GitHub for the first time, you should try to start off with a blank slate. Keep the old tickets in Trac or the database around for a period in time in case you need to reference, but don't migrate the entire Trac ticket repository. I could understand wanting to port in active tickets, though, which is a very valid use case for a tool like I am going to demonstrate. + +We were working with a client who was migrating off of their old Trac server, and I wanted to document (mostly for myself, but for anyone else who finds this too) how exactly it worked. + +## Tools + +There are plenty of tools out there on GitHub ([svigerske/trac-to-github](https://github.com/svigerske/trac-to-github), [robertoschwald/migrate-trac-issues-to-github](https://web.archive.org/web/20200912010021/https://github.com/robertoschwald/migrate-trac-issues-to-github), [hershwg/github-migrate-trac-tickets](https://github.com/hershwg/github-migrate-trac-tickets)... some of them require [XML-RPC](https://trac-hacks.org/wiki/XmlRpcPlugin), which I had a heck of a time installing on my Apache Trac webserver, so I wasn't able to test those. + +The two I have tested are: + +* [mavam/trac-hub (joshjohanning/trac-hub)](https://github.com/mavam/trac-hub) +* [trustmaster/trac2github](https://github.com/trustmaster/trac2github) + +Both of these are more focused on Issues, and neither really do attachments (see trac-hub how to section [below](#trac-hub-how-to) for a possible solution though). The [svigerske/trac-to-github](https://github.com/svigerske/trac-to-github) tool mentions that it uploads attachments as gists. + +Note: These tested against GitHub's Cloud instance (not server). + +## trac-hub + +### trac-hub Overview + +I originally tested out trac2github and was going to write about that, but I think I might like this [trac-hub](https://github.com/joshjohanning/trac-hub) tool a little better. When comparing the two, trac-hub might be better in that it uses the [Import Issues API](https://gist.github.com/jonmagic/5282384165e0f86ef105). The issues import API never left preview, so be aware it's not officially supported from GitHub. The benefits of using this API are that it can create issues: + +* without hitting abuse detection warnings and getting blocked +* without sending email notifications +* without increasing your contribution count to ridiculous heights +* much faster than with the normal issues API +* with correct creation/closed date set +* atomically without users being able to interfere in the creation of a single issue + +Basically, using this tool allows the GitHub issue to look like it was created originally when it was created in Trac versus looking like a brand new issue that was created. + +[I created a fork of mavam/trac-hub (joshjohanning/trac-hub)](https://github.com/joshjohanning/trac-hub) (that has since been [merged](https://github.com/mavam/trac-hub/pull/33)) that adds the ability to map Trac ticket owners to GitHub Issues assignees. Make sure not to typo the GitHub username as the issue will fail to create if a ticket's owner (assignee) has a mapping in the configuration file. If a mapping doesn't exist, the GitHub Issue will be created with no assignee, as expected. + +The caveats of both of the tools is that the "Issue creator" will appear as the one who originally ran the tool - but at least with this tool, we can preserve create and comment dates. + +### trac-hub How To + +I ran this on a debian 10.7 server, the same server that was running my Trac installation. + +Pre-requisites to install: + +* `sudo apt-get install bundler` +* `sudo apt-get install libmariadb-dev` +* `sudo apt-get install libsqlite3-dev` + +Instructions: + +1. Clone the repository - `git clone https://github.com/joshjohanning/trac-hub` +1. Rename the example configuration file - `mv config.yaml.example config.yaml` +1. Edit the configuration file `vim config.yaml` and modify the following sections: + 1. `trac` to provide the sqlite database path or mysql connection (untested) + 1. `github` to provide the target org/repo name and personal access token (make sure to grant the token enough access!) + 1. `users` to provide a list of mapping of Trac users --> GitHub. If a mapping doesn't exist, it won't use the GitHub handle and just refer to the user as the Trac user. In my version of the tool, if a mapping is found but the GitHub handle doesn't exist, it won't migrate tickets --> issues that are owned by/assigned to that non-existant user. Essentially, just make sure that the mapping uses a valid GitHub user or don't include it :) + 1. `labels` to provide a mapping of Trac ticket metadata to [labels in GitHub Issues](https://docs.github.com/en/github/managing-your-work-on-github/managing-labels) +1. Run the migration in a test repo! `sudo bundle exec trac-hub -v -s 1 -F`. Command line options: + 1. `-v` : verbose/debug logging + 1. `-s 1` : start at the the first ticket in Trac + 1. `-F` : fast import, import without safety-checking the issue number. The way this tool runs is it expects Trac ticket #1 to be created as Issue #1. If you already have issues in the repository, or you want to do testing, add the -F import. For your real migration, you could probably drop this and trac ticket #1 will map to issue #1. Otherwise, with this `-F` argument, the issues will be created even though the ID's won't make 1:1. + 1. No example here, but `-a` for attachment-urls is interesting. The tool doesn't migrate attachments, but if you used one of the `download-trac-attachment-*.sh`{: .filepath} scripts [in the repo](https://github.com/joshjohanning/trac-hub/tree/master/tools), you could host the files somewhere, presumably with the same file names and it will hyperlink the attachments?? + +### trac-hub Command Line Argument List + +```shell +$ bundle exec trac-hub --help + -c, --config config set the configuration file + -s, --start-at ID start migration from ticket with number + -r, --rev-map-file FILE allows to specify a commit revision mapping FILE + -a, --attachment-url URL if attachment files are reachable via a URL we reference this here + -S, --single-post Put all issue comments in the first message. + -F, --fast-import Import without safety-checking issue numbers. + -o, --opened-only Skips the import of closed tickets + -v, --verbose verbose mode +``` + +### trac-hub Example Migration Screenshots + +Migrating 5 tickets from Trac: +![trac-tickets](/assets/screenshots/2021-01-26-trac-to-github/trac-tickets.png){: .shadow } +_Original ticket list in Trac_ + +How they appear in GitHub: +![trac-hub-example-issues](/assets/screenshots/2021-01-26-trac-to-github/trac-hub-example-issues.png){: .shadow } +_Issues in GitHub migrated using trac-hub_ + +Trac ticket comments: +![trac-ticket-comments](/assets/screenshots/2021-01-26-trac-to-github/trac-ticket-comments.png){: .shadow } +_Original ticket comments in Trac_ + +Issue comments: +![trac-hub-example-issue-comments](/assets/screenshots/2021-01-26-trac-to-github/trac-hub-example-issue-comments.png){: .shadow } +_Issue comments in GitHub migrated using trac-hub_ + +## trac2github + +### trac2github Overview + +This was the first [tool](https://github.com/trustmaster/trac2github) that I used, and it works! It uses the regular GitHub Issues API, which is subject to rate limits and abuse detection. The team that tried to use this import with me ran into the abuse detection a few times and tweaked lines [484](https://github.com/trustmaster/trac2github/blob/master/trac2github.php#L484) and [485](https://github.com/trustmaster/trac2github/blob/master/trac2github.php#L485) of the php script to play more friendly with GitHub (decreasing `$request_count > 50)` and increasing `sleep(70)`. Note that these are only used if `$ticket_limit` is set in the [configuration file](https://github.com/trustmaster/trac2github/blob/master/trac2github.cfg#L68). + +The team did not like this tool because the original create date was not preserved - all of the issues look like they were created at the time the import tool ran. + +Otherwise, it does the job at migrating tickets, comments, user mapping with assignee, and label mapping. + +### trac2github How To + +I ran this on a debian 10.7 server, the same server that was running my Trac installation. + +Pre-requisites to install: + +* PHP (e.g. on Ubuntu/Debian `sudo apt-get install php`) (I prefer `sudo apt-get install php-fpm` to NOT install a new version of apache over my existing web server) +* Support for the trac database format, e.g. `sudo apt-get install php-mysql`, `sudo apt-get install php-sqlite3`, etc. +* `sudo apt-get install php-curl` + +Instructions: + +1. Clone the repository - `git clone https://github.com/joshjohanning/trac-hub` +1. Edit the configuration file `vim config.yaml` and modify the following variables: + 1. `$username` : GitHub username + b. `$password` GitHub personal access token (make sure to grant the token enough access!) + c. `$project` : The GitHub organization you are migrating to. If migrating issues to your own GitHub repo under your own account, this would be the same as `$username`. Use the Organization name if using GitHub Enterprise + 1. `$repo` : The GitHub repo name + 1. `$users_list` : Provide a mapping of Trac user --> GitHub user + 1. The database driver settings - whether that's `$mysqlhost_*`, `$sqlite_trac_path`, or `$pgsql_` settings. + 1. Explore the [other items in the config file](https://github.com/trustmaster/trac2github/blob/master/trac2github.cfg) to see if they are needed, such as `$ticket_offset` for resuming a migration or `$remap_labels` to modify the label mapping. Note that while you might not think you need to set `$ticket_limit` because you want to migrate the entire Trac ticket database, this setting needs to be set in order to trigger the [aforementioned](#trac2github-overview) rate limiting sleep control. **Therefore, I advise giving `$ticket_limit` an arbitrary value for this purpose.** +1. Run the import: `php trac2github.php` + +You'll notice that this import is a lot slower than trac-hub :). + +### trac2github Example Migration Screenshots + +Migrating 5 tickets from Trac: +![trac-tickets](/assets/screenshots/2021-01-26-trac-to-github/trac-tickets.png){: .shadow } +_Original ticket list in Trac_ + +How they appear in GitHub: +![trac-hub-example-issues](/assets/screenshots/2021-01-26-trac-to-github/trac2github-example-issues.png){: .shadow } +_Issues in GitHub migrated using trac2github_ + +Trac ticket comments: +![trac-ticket-comments](/assets/screenshots/2021-01-26-trac-to-github/trac-ticket-comments.png){: .shadow } +_Original ticket comments in Trac_ + +Issue comments: +![trac-hub-example-issue-comments](/assets/screenshots/2021-01-26-trac-to-github/trac2github-example-issue-comments.png){: .shadow } +_Issue comments in GitHub migrated using trac2github_ + +## Takeaways + +Run a few of these migrations, and run them in repositories that you don't mind deleting afterwards as deleting issues can be...challenging. + +Run the migration using someone's PAT that you don't mind being the creator for the Issues (throwaway / service account possibly??). + +Make sure you have the users created in GitHub and mapped in the appropriate configuration file ahead of time. + +Be patience, have reasonable expectations, and good luck! diff --git a/_posts/2021-06-17-angular-tokenization.md b/_posts/2021-06-17-angular-tokenization.md new file mode 100644 index 0000000..270d778 --- /dev/null +++ b/_posts/2021-06-17-angular-tokenization.md @@ -0,0 +1,201 @@ +--- +title: 'Tokenizing Angular Environment Configuration in Azure DevOps' +author: Josh Johanning +date: 2021-06-17 17:30:00 -0600 +description: Using the 'build once deploy many' concepts with an Angular application and Azure Pipelines +categories: [Azure DevOps, Pipelines] +tags: [Azure DevOps, Azure Pipelines, Angular] +--- + +## Overview + +I was working with a team that had an Angular front-end application and I was tasked with improving their CI/CD process. They had some automated pipelines, but they were running a build before each environment by running a different `npm run build -- --prod --configuration ` command. + +My co-worker Colin Dembovsky summarizes it well in a [similar post for .NET Core](https://colinsalmcorner.com/managing-config-for-net-core-web-app-deployments-with-tokenizer-and-replacetokens-tasks/): +> **The Build Once Principle** +> +> If you’ve ever read any of my blogs you’ll know I’m a proponent of the “build once” principle. That is, your build should be taking source code and (after testing and code analysis etc.) producing a single package that can be deployed to multiple environments. The biggest challenge with a “build once” approach is that it’s non-trivial to manage configuration. If you’re building a single package, how do you deploy it to multiple environments when the configuration is different on those environments? + +Basically, if you're running a new build for each environment, you might as well not do any tests after your Dev build because there's no way you can guarantee that the binaries build for Dev are the same as the ones going into Production. Never mind that it's inefficient and wastes time - you already compiled your code once, why do it again? The only things that should differ between environments should be the environment-specific configuration (such as a connection string, or in my case, the back-end API url). + +I'm taking the concepts from that post and my experience with a few other clients and will be doing something very similar to that here! + +## The Problem + +The particular challenge with Angular is that the build output is not the same file name every time - you can't just swap in a new file with the proper values. See screenshot for th `main.js`{: .filepath} files from two builds: + +![main.js example](/assets/screenshots/2021-06-17-angular-tokenization/main.js.png){: .shadow } +_Compiled main.js output from two different builds_ + +## Solution and File Edits + +We are going to make a few modifications and additions to the Angular code, but most of the changes will come in the pipeline. + +Pre-requisites: +* [Qetza's Replace Tokens](https://marketplace.visualstudio.com/items?itemName=qetza.replacetokens) extension installed in the Azure DevOps organization + + +### angular.json + +Your `angular.json`{: .filepath} file might look a little different, but what I did was take an existing configuration, copy/paste it, and change the `fileReplacements` section, specifically the `src/environments/environment.tokenized.ts`{: .filepath} line. + +```json + "configurations": { + ... + "tokenized": { + "fileReplacements": [{ + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.tokenized.ts" + }], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [{ + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "4mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "100kb", + "maximumError": "150kb" + } + ] + } + } + } +``` +{: file='angular.json'} + +## environments.tokenized.ts + +Similarly, I copied an existing `src/environments/environments.*.ts`{: .filepath} file to create the `environments.tokenized.ts`{: .filepath} file that my new configuration will use. The important line here is the `baseUrl: '#{baseUrl}#'` line. Notice how I'm creating a token here with the `#{token-name}#` syntax. This is the syntax of the token that our deployment process will know to find and replace. + +```js +import { NgxLoggerLevel } from 'ngx-logger'; + +export const environment = { + production: true, + baseUrl: '#{baseUrl}#', + webUrl: location.origin, + loggerLevel: NgxLoggerLevel.OFF +}; +``` +{: file='environments.tokenized.ts'} + +## azure-pipelines.yml + +Finally, we just need to modify our pipeline file! I'll break down the changes in chunks. + +### 1. NPM Build + +In the Build job, update the **[NPM build](https://docs.microsoft.com/en-us/azure/devops/pipelines/artifacts/npm?view=azure-devops&tabs=yaml)** command. In this example, the command this task will produce will be `npm run build -- --prod --configuration=tokenized`. Alternatively, you may just opt to use a [script task](https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema%2Cparameter-schema#script), but the `Npm@1` task also works. + +```yml +- task: Npm@1 + displayName: "npm build" + inputs: + command: custom + workingDir: src + verbose: false + customCommand: run build -- --configuration=tokenized +``` + +### 2. Add Variables for your Tokens + +For each token you have, add a like-named **[variable](https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema%2Cparameter-schema#variables)**. The task uses the variable name/value to find/replace in the tokenized file. Here's an example of setting a variable at the stage level (ie: Deploy to Dev), but the variable could also be scoped at the job level. Just note that the variable is created without the `#{` prefix or `}#` suffix - in other words, just create the variable as the token name without the wrappings. + +```yaml +- stage: deployDev + displayName: "Deploy Dev" + variables: + baseUrl: https://mysite-dev.azurewebsites.net/ +``` + +### 3. Extract Files + +I'm assuming your build artifacts are [published to the Pipeline](https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema%2Cparameter-schema#publish), which is going to create a zip. In the Deployment job, we need to **[unzip the artifact](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/utility/extract-files?view=azure-devops)** before we can inject the real values in place of the tokens. + +```yaml +- task: ExtractFiles@1 + displayName: 'Extract files' + inputs: + archiveFilePatterns: '$(pipeline.workspace)/**/*.zip' + destinationFolder: '$(Pipeline.Workspace)/deploy' +``` + +* Unzip Note 1: If you are running on your own ubuntu agents, make sure `unzip` is installed first! +* Unzip Note 2: Similarly, if you are running on your own agents, the above example requires the [workspace to be cleaned](https://docs.microsoft.com/en-us/azure/devops/pipelines/process/phases?view=azure-devops&tabs=yaml#workspace) for each run otherwise on the second run it will find more than one `.zip`{: .filepath} file to extract. You could alternatively use a stronger typed pattern than the `**/*.zip`{: .filepath} pattern in finding your zips, of course. + +### 4. Replace Tokens + +Right after you extract the contents of the artifact zip, add in the **[Replace Tokens](https://marketplace.visualstudio.com/items?itemName=qetza.replacetokens)** task. Note how my `rootDirectory` parameter is the same as the `destinationFolder` parameter from the unzip task. Additionally, `targetFiles` parameter is using a pattern to find the `main*.js`{: .filepath} file, no matter what the file gets named for each build. + +```yaml +- task: qetza.replacetokens.replacetokens-task.replacetokens@3 + displayName: 'Replace tokens' + inputs: + rootDirectory: '$(pipeline.workspace)/deploy' + targetFiles: '**/main*.js' + escapeType: none + verbosity: detailed +``` + +* Replace Tokens Note 1: Normally I don't use the *full* name when referencing pipeline tasks, but if you also have [Colin's ALM Corner Build and Release Tools](https://marketplace.visualstudio.com/items?itemName=colinsalmcorner.colinsalmcorner-buildtasks), you'll run into an ambiguous task name error. +* Replace Tokens Note 2: If you opted to not use the default token prefix/suffix like `#{token-name}#`, you can add in the `tokenPrefix` and `tokenSuffix` parameters here as well. Further documentation is [here](https://github.com/qetza/vsts-replacetokens-task). +* Replace Tokens Note 3: If you had tokens in other `.js`{: .filepath} files, you could simply use a `**/*.js`{: .filepath} pattern. + +### 5. Azure Web App Deploy + +Assuming your deploying this **[Web App to Azure](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/deploy/azure-rm-web-app?view=azure-devops)**, update your task to use the new folder location *instead* of the zip package. The input takes either a zip or a folder, so we could have zipped our folder back up after running the replace tokens task, but there is no need. We simply need to use the same path for `package` that we used above for `destinationFolder` and `rootDirectory`. + +```yaml +- task: AzureWebApp@1 + displayName: 'Deploy Azure Web App' + inputs: + azureSubscription: MyAzureSubscription + appType: 'webAppLinux' + appName: WebAppName + package: '$(Pipeline.Workspace)/deploy' +``` + +## Summary + +The **build job** will use our `tokenized` configuration to run the build and use our `#{baseUrl}#` token to use when compiling instead of a Dev or Prod URL. When you build locally, you can still use whatever other configuration you want without having to worry about the tokenized value. Just be sure to keep your config in sync, that is if you add or change something to one `environment*.ts`{: .filepath} file, make sure to remember to do the tokenized one as well. + +The **deployment job** will... +* take your published artifact zip from build +* extract it +* use the variable with the same name as the token to inject the value in for the right deployment environment +* deploy your web app like normal! +* rinse and repeat for all of your environments + +![Replace Tokens Workflow](/assets/screenshots/2021-06-17-angular-tokenization/replace-tokens.png){: .shadow } +_Replace Tokens output in the pipeline_ + +## Build Configuration / File Replacement Update 08/12/2021 + +I saw this in an `angular.json`{: .filepath} file and had to update this post. This might be a more elegant solution than creating an entirely new `tokenized` build configuration: + +```json + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "apps/My-Angular-App/src/environments/environment.ts", + "with": "apps/My-Angular-App/src/environments/environment.prod.ts" + } + ], + ... + } + } +``` +{: file='angular.json'} + +This encapsulates the best of both worlds - we are still building once and deploying many, but we also don't need a specialized build configuration to run through. We can use the normal production build configuration and file replace the tokenized `environments.prod.ts`{: .filepath} with `environment.ts`{: .filepath} at build time. + +The deployment replace tokens task will replace the tokens with the proper environment-specific variable configuration. diff --git a/_posts/2021-08-10-azdo-delete-custom-field.md b/_posts/2021-08-10-azdo-delete-custom-field.md new file mode 100644 index 0000000..ca0d6b6 --- /dev/null +++ b/_posts/2021-08-10-azdo-delete-custom-field.md @@ -0,0 +1,54 @@ +--- +title: 'Azure DevOps: Delete Custom Fields on Process Template' +author: Josh Johanning +date: 2021-08-10 22:30:00 -0600 +description: Delete custom fields on a process template in Azure DevOps using the REST API +categories: [Azure DevOps, Work Items] +tags: [Azure DevOps, Work Items] +--- + +## Overview + +I had the annoying misfortune today of running into an issue in Azure DevOps when customizing a process template. I added a field to a work item but I created the field as the wrong type. Once the custom field is created, there is not way to delete the field through the UI. Even with the REST API, it was a little tricky to find. + +## Deleting the Field + +First: You'll need to be a Project Collection Administrator in order to run this API. + +This is a [link to the API](https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/fields/delete?view=azure-devops-rest-6.0) we are going to use. + +``` +DELETE https://dev.azure.com/{organization}/{project}/_apis/wit/fields/{fieldNameOrRefName}?api-version=6.0 + +``` + +In Postman, let's create a new request with that URL string. I would have thought we would have passed in the Work Item `Process Template Name` instead of the `Project`, but I suppose it knows based on the project what process template it is using. + +Here is an example for my Azure DevOps organization / team project deleting the `NewTestField`. + +``` +https://dev.azure.com/jjohanning0798/TestTeamProject/_apis/wit/fields/NewTestField?api-version=6.0 +``` + + +Additionally, we should create a [Personal Access Token (PAT)](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) with full permissions. + +Under the Postman authentication's tab, we can leave the username blank and enter the PAT for the password. Use Basic Authentication. +![Postman authentication](/assets/screenshots/2021-08-10-azdo-delete-custom-field/postman-auth.png){: .shadow } +_Setting up Basic authorization in Postman with a PAT_ + +Send the request. + +If it was successful, you will see a `204 No Content` message near the +![Postman authentication](/assets/screenshots/2021-08-10-azdo-delete-custom-field/postman-response.png){: .shadow } +_Successful request in Postman_ + +The field should no longer appear on our work item, and we can re-create the field with the right name and type. + +If you don't have the proper permissions (ie: Project Collection Administrator), you'll receive the following message: + +``` +"message": "Access Denied: 08dd71ec-5369-619d-bc32-495207cd99b7 needs the following permission(s) on the resource Delete field from organization to perform this action: Delete field from organization", +``` + +Enjoy! diff --git a/_posts/2021-08-12-pipeline-templates.md b/_posts/2021-08-12-pipeline-templates.md new file mode 100644 index 0000000..10f3ee6 --- /dev/null +++ b/_posts/2021-08-12-pipeline-templates.md @@ -0,0 +1,18 @@ +--- +title: 'Azure DevOps: Pipeline Templates' +author: Josh Johanning +date: 2021-08-12 22:00:00 -0600 +description: My personal Azure DevOps pipeline templates +categories: [Azure DevOps, Pipelines] +tags: [Azure DevOps, Azure Pipelines, Pipeline Templates] +--- + +## Overview + +Although linked in various posts in this blog, I never fully advertise my `pipeline-templates` GitHub repository. I refer back to this every so often to see how I accomplished something in the past with various pipeline YAML concepts and will often share to others when they are in need of an example to follow. + +Link: [https://github.com/joshjohanning/pipeline-templates](https://github.com/joshjohanning/pipeline-templates) + +In each folder, you will typically see the build as well as the deployment yaml template. I also started including the `azure-pipelines-*.yml`{: .filepath} file to show an example of how to consume/reference the pipeline templates. + +Let me know if there are any questions or areas for improvement! diff --git a/_posts/2021-08-18-agent-pool-error.md b/_posts/2021-08-18-agent-pool-error.md new file mode 100644 index 0000000..e6f7f24 --- /dev/null +++ b/_posts/2021-08-18-agent-pool-error.md @@ -0,0 +1,30 @@ +--- +title: 'Azure DevOps: No agent pool found with identifier xx' +author: Josh Johanning +date: 2021-08-18 7:00:00 -0600 +description: A solution to the vague error message that occurs sometimes when deleting/re-creating an agent pool +categories: [Azure DevOps, Pipelines] +tags: [Azure DevOps, Azure Pipelines] +--- + +## Overview + +We are using the Virtual Machine Scale Set (VMSS) Azure DevOps agents pretty heavily. They are perfect for our situation, needing to be able to deploy to services locked down by private endpoints while not having to individually manage agent installation/configurations. + +I re-created a VMSS to use a different .vhd image, and thought I had to delete/re-create the agent pool in Azure DevOps. I learned afterwards this isn't the best way, the best way is to just edit the existing agent pool and point to your Azure Subscription --> VMSS again to re-configure it. + +I had deleted agent pools within a project no problem before, but this time, I actually wasn't the one that had originally created this pool. I went to go re-create the agent pool with the exact same name and received this error message: + +> No agent pool found with identifier 59. + +I suspected I was able to delete the agent pool from the project because I was a Project Administrator, but I wasn't able to delete from the organization/collection since 1) I wasn't the creator of the agent pool, they are assigned special rights and 2) I wasn't a Project Collection Administrator. + +However, I was surprised to find that I couldn't even *see* the agent pool in the list. + +> https://dev.azure.com/ORG/_settings/agentpools + +I tried querying the REST API and it didn't appear there. + +Since I didn't know any of the Project Collection Administrators in this organization, my solution was to ask the *original creator* to go to the Organization --> Agent Pools settings to delete the agent pool so I could re-create it. + +Lesson learned - don't delete, just edit :). But not a super helpful error message from Azure DevOps's part. diff --git a/_posts/2021-09-03-azure-devops-code-coverage.md b/_posts/2021-09-03-azure-devops-code-coverage.md new file mode 100644 index 0000000..af60305 --- /dev/null +++ b/_posts/2021-09-03-azure-devops-code-coverage.md @@ -0,0 +1,154 @@ +--- +title: 'The Easiest Way to Generate and Publish .NET Code Coverage in Azure DevOps' +author: Josh Johanning +date: 2021-09-03 4:00:00 -0500 +description: Publishing Code Coverage and making it look pretty in Azure DevOps is way harder than it should be +categories: [Azure DevOps, Pipelines] +tags: [Azure DevOps, Code Coverage] +image: + path: /assets/screenshots/2021-09-03-azure-devops-code-coverage/good-code-coverage.png + width: 816 # in pixels + height: 582 # in pixels + alt: Cobertura Code Coverage in Azure DevOps +--- + +## Overview + +Publishing code coverage in Azure DevOps and making it look pretty is way harder than it should be. It's something that sounds simple, oh just check the box on the task - but nope you have to make sure to read the notes and add the additional parameter to the test task. Okay great, now you have a code coverage tab, but what is this .coverage file and how do I open it? That's not very user friendly. And don't get me started on having to wait for the *entire* pipeline to finish before you can even *see* the code coverage tab - nonsensical. + +If you want to navigate to the solution, [scroll down](#the-better-way). + +## Not Good: The Out of the Box Way + +If using the out of the box `dotnet` task with the `test` command, simply add the `publishTestResults` argument (or if using the task assistant, check the `Publish test results and code coverage` checkbox): +![adding dotnet test task](/assets/screenshots/2021-09-03-azure-devops-code-coverage/adding-test-task.png){: width="350" }{: .shadow } +_.NET Core Task Assistant UI_ + +However, if you read the information on the `publishTestResults` argument from the [.NET Core CLI task](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/build/dotnet-core-cli?view=azure-devops#arguments) (or clicking on the `(i)` on the `Publish test results and code coverage` option in the task assistant), it says: + +> Enabling this option will generate a test results TRX file in `$(Agent.TempDirectory)` and results will be published to the server. +> This option appends `--logger trx --results-directory $(Agent.TempDirectory)` to the command line arguments. +> +> **Code coverage can be collected by adding `--collect "Code coverage"` option to the command line arguments. This is currently only available on the Windows platform.** + +Emphasis: mine. So even if you check the box, you need to ensure you add the `--collect "Code coverage"` argument. Oh, and you have to run this on a Windows agent, so no `ubuntu-latest` for us. + +This produces code coverage that looks like the following in Azure DevOps: +![.coverage file code coverage](/assets/screenshots/2021-09-03-azure-devops-code-coverage/bad-code-coverage.png ){: width="400" }{: .shadow } +_How the default code coverage in Azure DevOps looks_ + +It's a link to a .coverage file..which is great if you 1) have Visual Studio installed and 2) are on Windows (can't open .coverage file on Mac). + +## The Better Way + +The better way is to add the [coverlet.collector](https://github.com/coverlet-coverage/coverlet) NuGet package to (each of) the test project(s) that you run `dotnet test` against. + +The easiest way to do this is to run the `dotnet package add` command targeting the test project: + +`dotnet add package coverlet.collector` + +![dotnet add package](/assets/screenshots/2021-09-03-azure-devops-code-coverage/dotnet-add-package.png){: .shadow } +_Adding coverlet.collector with dotnet add package_ + +For those who can't run the `dotnet` command, add the [following](https://github.com/joshjohanning/PrimeService-unit-testing-using-dotnet-test/commit/43067b4e035eb45899e185e701bd4aaf8575514b) under the `ItemGroup` block in the `.csproj`{: .filepath} file: + +```xml + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + +``` + +See the [following page](https://www.nuget.org/packages/coverlet.collector/) for the latest version. + +Next, update the pipeline to ensure your `dotnet test` command looks like mine, and adding the `PublishCodeCoverageResults@1` task. + +```yml + # Add coverlet.collector nuget package to test project - 'dotnet add package coverlet.collector' + - task: DotNetCoreCLI@2 + displayName: dotnet test + inputs: + command: 'test' + projects: '**/*.Tests.csproj' + arguments: '--configuration $(buildConfiguration) --collect:"XPlat Code Coverage"' + publishTestResults: true + + # Publish code coverage report to the pipeline + - task: PublishCodeCoverageResults@1 + displayName: 'Publish code coverage' + inputs: + codeCoverageTool: Cobertura + summaryFileLocation: $(Agent.TempDirectory)/*/coverage.cobertura.xml # using ** instead of * finds duplicate coverage files +``` + +The `--collect:"XPlat Code Coverage"` argument is what tells `dotnet test` to use the `coverlet.collector` package to generate us a cobertura code coverage report. As you can guess by the `XPlat` in the argument, this runs cross platform on both Windows and Ubuntu. + +This argument creates a `$(Agent.TempDirectory)/*/coverage.cobertura.xml`{: .filepath} code coverage report file. This folder is default output folder since Azure DevOps adds `--results-directory /home/vsts/work/_temp` to the command. + +Next, we have to specifically add the `PublishCodeCoverageResults@1` task to publish the code coverage output to the pipeline. It seems like at least with my project, it produces 2 `coverage.cobertura.xml`{: .filepath} files and that throws a warning in the pipeline, so that's why I used `$(Agent.TempDirectory)/*/coverage.cobertura.xml`{: .filepath} and NOT `$(Agent.TempDirectory)/**/coverage.cobertura.xml`{: .filepath}. + +![duplicate code coverage reports](/assets/screenshots/2021-09-03-azure-devops-code-coverage/find-code-coverage.png){: .shadow } +_Duplicate coverage.cobertura.xml code coverage results_ + +Now, after the *entire* pipeline has finished (including any of the deployment stages), we will have a code coverage tab with a way more visually appealing code coverage report: +![cobertura code coverage in azure devops](/assets/screenshots/2021-09-03-azure-devops-code-coverage/good-code-coverage.png){: .shadow } +_Cobertura Code Coverage Report in Azure DevOps_ + +> Here's the [full sample YML pipeline](https://github.com/joshjohanning/PrimeService-unit-testing-using-dotnet-test/blob/main/azure-pipelines.yml#L36-L55) for this example. +{: .prompt-tip } + +## Why not ReportGenerator? + +I've seen many blog posts that are similar to mine, except that they have the `reportgenerator@4` task. I used to think this was required, too! But I have recently found out it is not - at least not if you have **ONE** test project you are publishing results for. + +If you have multiple test projects you would like code coverage published for, then yes, the `reportgenerator@4` task is needed. + +I have started to use the command line instead of the actual `reportgenerator@4` task itself, though, as not every organization has the [marketplace extension installed](https://marketplace.visualstudio.com/items?itemName=Palmmedia.reportgenerator) (and some have stricter policies about adding extensions than others). Additionally, if you use the CLI, you have more control over which version of ReportGenerator is ultimately used. + +```yml + # First install the tool on the machine, then run it + - script: | + dotnet tool install -g dotnet-reportgenerator-globaltool + reportgenerator -reports:$(Agent.WorkFolder)/**/coverage.cobertura.xml -targetdir:$(Build.SourcesDirectory)/CodeCoverage -reporttypes:'HtmlInline_AzurePipelines;Cobertura' + # IMPORTANT - set `disable.coverage.autogenerate` to true if you want to use older reportgenerator version + echo "##vso[task.setvariable variable=disable.coverage.autogenerate;]true" + displayName: Create code coverage report +``` + +`reportgenerator@4` equivalent: + +```yml + # ReportGenerator extension to combine code coverage outputs into one + - task: reportgenerator@4 + inputs: + reports: '$(Agent.WorkFolder)/**/coverage.cobertura.xml' + targetdir: '$(Build.SourcesDirectory)/CoverageResults' +``` + +One of these steps needs to run before the `PublishCodeCoverageResults@1` task, and that task needs to be updated just a little bit (the xml file path is different). See: + +```yml + # Publish the combined code coverage to the pipeline + - task: PublishCodeCoverageResults@1 + displayName: 'Publish code coverage report' + inputs: + codeCoverageTool: 'Cobertura' + summaryFileLocation: '$(Build.SourcesDirectory)/CoverageResults/Cobertura.xml' + reportDirectory: '$(Build.SourcesDirectory)/CoverageResults' +``` + +> Here's the [full sample YML pipeline](https://github.com/joshjohanning/PrimeService-unit-testing-using-dotnet-test/blob/reportgenerator-v4.6.1/azure-pipelines.yml#L47-L62) for this example calling `ReportGenerator` ourselves. +{: .prompt-tip } + +## Code Coverage Tab Not Showing Up? + +This doesn't really solve the [problem](https://developercommunity.visualstudio.com/t/code-coverage-does-not-show-up-until-multistage-pi/786733) where Azure DevOps will not show the Code Coverage tab until the *entire* pipeline (including all deployments) has completed. In cases where code coverage is important, I either: + +1. Change the report output from `$(Build.SourcesDirectory)` to `$(Build.ArtifactStagingDirectory)` and [publish](https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema%2Cparameter-schema#publish) the report as a pipeline artifact. +1. Create a *separate* build pipeline than deployment pipeline. This harkens back to the day where we didn't have multi-stage YAML builds and we had separate Build Definitions and Release Definitions. We can set up the deployment yml pipeline to be [triggered](https://docs.microsoft.com/en-us/azure/devops/pipelines/process/pipeline-triggers?tabs=yaml&view=azure-devops#configure-pipeline-resource-triggers) from the completion of the build yml pipeline. + +## Conclusion + +Now you know the ins and outs of adding Code Coverage to your .NET (Core) projects in Azure DevOps. + +Stay tuned to my next post on what we can do with [code coverage in GitHub Actions](/posts/github-code-coverage/)! diff --git a/_posts/2021-09-08-github-code-coverage.md b/_posts/2021-09-08-github-code-coverage.md new file mode 100644 index 0000000..df7e736 --- /dev/null +++ b/_posts/2021-09-08-github-code-coverage.md @@ -0,0 +1,150 @@ +--- +title: 'GitHub Actions: Publish Code Coverage Summary to Pull Request and Job Summary' +author: Josh Johanning +date: 2021-09-08 22:00:00 -0500 +description: Using GitHub Actions to add a code coverage summary report comment to a pull request and job summary +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, Pull Requests, Code Coverage] +media_subpath: /assets/screenshots/2021-09-08-github-code-coverage +image: + path: github-action-pr-post-image.png + width: 100% + height: 100% + alt: Code Coverage summary posted to a pull request comment using an Action from the GitHub Actions Marketplace +--- + +## Overview + +This is a follow-up to my previous post: [The Easiest Way to Generate and Publish .NET Code Coverage in Azure DevOps](/posts/azure-devops-code-coverage/) + +I was familiar with adding Code Coverage to my pipelines in Azure DevOps and having a Code Coverage tab appear on the pipeline summary page, but I wasn't sure what was available for GitHub Actions. With GitHub Actions really starting to pick up steam, especially with recent additions such as [Composite Actions](https://www.colinsalmcorner.com/github-composite-actions/), I thought now would be a great time to explore. + +## Adding Code Coverage to Pull Request + +I found this GitHub Action in the marketplace - [Code Coverage Summary](https://github.com/marketplace/actions/code-coverage-summary). There might be others, but this one seemed simple and had the functionality I was looking for. + +This post assumes you are using the `coverlet.collector` [NuGet package](https://www.nuget.org/packages/coverlet.collector/). For a refresher, see the "[the better way](/posts/azure-devops-code-coverage/#the-better-way)" section of my previous post. + +Here's the relevant part of my GitHub Actions [workflow file](https://github.com/joshjohanning/PrimeService-unit-testing-using-dotnet-test/blob/main/.github/workflows/dotnet.yml): + +```yml + # Add coverlet.collector nuget package to test project - 'dotnet add package coverlet + - name: Test + run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" --logger trx --results-directory coverage + + - name: Code Coverage Summary Report + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: 'coverage/*/coverage.cobertura.xml' + badge: true + format: 'markdown' + output: 'both' + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + if: github.event_name == 'pull_request' + with: + recreate: true + path: code-coverage-results.md + + - name: Write to Job Summary + run: cat code-coverage-results.md >> $GITHUB_STEP_SUMMARY +``` +{: file='.github/workflows/dotnet.yml'} + +Note the test command here that we are using to generate the Cobertura code coverage summary file: + +```bash +dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" --logger trx --results-directory coverage +``` +{: .nolineno} + +The next action is the [Code Coverage Summary Report](https://github.com/irongut/CodeCoverageSummary) action: + +> CodeCoverageSummary Inputs: +> * filename: `coverage/*/coverage.cobertura.xml`{: .filepath} +> * badge: **true** | false +> * format: **markdown** | text +> * output: console | file | **both** + +```yml + - name: Code Coverage Summary Report + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: 'coverage/*/coverage.cobertura.xml' + badge: true + format: 'markdown' + output: 'both' +``` + +> The [CodeCoverageSummary v1.3.0](https://github.com/irongut/CodeCoverageSummary/releases/tag/v1.3.0) action now supports glob pattern matching for multiple coverage files. Therefore, you don't have to use the example below with [reportgenerator](#reportgenerator) to combine the code coverage report before processing! +{: .prompt-tip } + +This would be enough to show the code coverage in the action run: +![github action code coverage report](github-action-code-coverage.png){: .shadow } +_Code Coverage Summary Report in the Action run logs_ + +However, the fun doesn't stop there. How useful would it be to post this to the PR so it's nice and easy for reviewers? Well, the next [action](https://github.com/marketplace/actions/sticky-pull-request-comment) shows a simple way we can add (and sticky) a PR comment with our code coverage report: + +```yml + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + if: github.event_name == 'pull_request' + with: + recreate: true + path: code-coverage-results.md +``` + +Perfect - nothing for us to configure here, either. On the pull request, this comment is added: + +![github action pull request](github-action-pr.png){: .shadow } +_Code Coverage Summary Report added as a pinned comment to the Pull Request_ + +This is also demonstrated on my [pull request here](https://github.com/joshjohanning/PrimeService-unit-testing-using-dotnet-test/pull/2). + +You'll notice the badge along with the markdown table summarizing the code coverage report. + +The nice thing with this action is that if a new commit is pushed to the PR triggering a new action run, the comment will be deleted/re-added with the updated code coverage summary. + +## Adding Code Coverage to Job Summary + +I think this is looking great, but what if we don't happen to create a pull request, how can we neatly see our code coverage report? Well, since May 9, 2022 (and GitHub Enterprise Server >= 3.6.0) we can use the [Job Summary](https://github.blog/changelog/2022-05-09-github-actions-enhance-your-actions-with-job-summaries/)! + +Since the CodeCoverageSummary action is already generating the markdown for us, all we have to do is append it to the [`$GITHUB_STEP_SUMMARY`](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary) environment variable. Add in the following run command to the end of the job: + +```yml + - name: Write to Job Summary + run: cat code-coverage-results.md >> $GITHUB_STEP_SUMMARY +``` + +Now, it publishes to the job summary page right next to the workflow run logs! See the screenshot below: + +![Job Summary in GitHub Actions](github-action-code-coverage-job-summary.png){: .shadow } +_Code Coverage Summary Report added to the job summary_ + +## ReportGenerator? + +If you are trying to run this on a Windows runner, you will quickly notice the [Code Coverage Summary Report](https://github.com/irongut/CodeCoverageSummary) action is a Docker-based container action, [meaning it _only_ runs on Linux runners](/posts/github-container-jobs/#caveats). Or perhaps you work in a GitHub instance that uses an [Actions allow list](https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization) to only allow approved actions to run. Don't worry! You can still very easily generate a nice-looking code coverage report and upload to the job summary. + + I have [documented this for Azure DevOps](/posts/azure-devops-code-coverage/#why-not-reportgenerator) in the past, but this would be the GitHub equivalent: + +```yml + - name: Create code coverage report + run: | + dotnet tool install -g dotnet-reportgenerator-globaltool + reportgenerator -reports:coverage/*/coverage.cobertura.xml -targetdir:CodeCoverage -reporttypes:'MarkdownSummaryGithub,Cobertura' + + - name: Write to Job Summary + run: cat CodeCoverage/SummaryGithub.md >> $GITHUB_STEP_SUMMARY +``` +{: file='.github/workflows/dotnet.yml'} + +This is what it looks like in the job summary: +![github action code coverage report](github-action-reportgenerator-job-summary.png){: .shadow } +_Code Coverage Summary Report generated by `reportgenerator` added to the job summary_ + +## Conclusion + +Maybe not _as pretty_ as the Cobertura report shown in Azure DevOps, but just as effective! Certainly the addition of [job summaries](https://github.blog/changelog/2022-05-09-github-actions-enhance-your-actions-with-job-summaries/) makes this a better experience. + +And hey, now on the GitHub Pull Request, you get to actually see the code coverage report before the *[end of the entire pipeline run](/posts/azure-devops-code-coverage/#code-coverage-tab-not-showing-up)* like in Azure DevOps 😀. diff --git a/_posts/2021-09-29-azure-devops-migrate-work-items.md b/_posts/2021-09-29-azure-devops-migrate-work-items.md new file mode 100644 index 0000000..a54e12d --- /dev/null +++ b/_posts/2021-09-29-azure-devops-migrate-work-items.md @@ -0,0 +1,182 @@ +--- +title: 'Azure DevOps: Migrate Work Items to New Organization / Project' +author: Josh Johanning +date: 2021-09-29 20:35:00 -0500 +description: Migrating work items to a different project or organization in Azure DevOps isn't as easy it should be. There are various ways to do so, with various levels of complexity and various levels of fidelity (history, for example). +categories: [Azure DevOps, Work Items] +tags: [Azure DevOps, Work Items, Migrations] +image: + path: /assets/screenshots/2021-09-29-azure-devops-migrate-work-items/bulk-move-team-project.png + width: 584 # in pixels + height: 361 # in pixels + alt: Migrating work items to a new project in Azure DevOps +--- + +## Overview + +If you have used Azure DevOps for a long time, you probably have asked / been asked if you can just simply move work items in one project to another. Maybe there was a company re-org, or the work was created in a 'temporary' project and needs a final resting spot, or you're migrating to a new Azure DevOps organization for whatever reason. If you are vaguely aware of the tool, you'll know that this can sometimes be easier said than done, especially if you want a migration with any level of fidelity. Not to say a full-fidelity migration is the end-all-be-all - sometimes the ask can be solved *with considerably less effort* if one was to just import the work items into the new project and if history is needed, simply refer to the old project. In like 3 weeks, the history will be meaningless anyways. In this post, I want to detail some options that you have as well as some caveats and gotchas. + +I want to highlight at a high level the options: + +| Scenario | Consideration | Option(s) | +|---|---|---| +| Moving work items to a project within the same organization | Full fidelity | Native '[move work item](https://docs.microsoft.com/en-us/azure/devops/boards/backlogs/move-change-type?view=azure-devops#move-a-work-item-to-another-project)' tool in Azure DevOps | +| Moving work items to a project in a different organization | Not full fidelity - just need the work items | Excel integration using [Office Integration Tools](https://docs.microsoft.com/en-us/azure/devops/boards/backlogs/office/bulk-add-modify-work-items-excel?view=azure-devops&tabs=agile-process#import-work-items-flat-list) (Windows-only) or [CSV Import/Export](https://docs.microsoft.com/en-us/azure/devops/boards/queries/import-work-items-from-csv?view=azure-devops) (cross-platform) | +| Moving work items to a project in a different organization | Full fidelity | [nkdAgility/azure-devops-migration-tools](https://github.com/nkdAgility/azure-devops-migration-tools) or [microsoft/vsts-work-item-migrator](https://github.com/Microsoft/vsts-work-item-migrator) | + +## Moving work items to a project within the same organization + +Lucky you, this is the easiest. In the [April 13 2016 sprint update](https://web.archive.org/web/20220110230445/https://docs.microsoft.com/en-us/azure/devops/release-notes/2016/apr-13-team-services) (that didn't make it to Azure DevOps Server until 2019!!), the Azure DevOps team added the ability to move a work item between projects within the same organization. As quoted from the sprint note: + +> Users may now move a work item(s) between team projects. The work item ID remains the same and all of the work item's revisions are moved. Users may also change type during a move and add a comment to be included as part of the work item's discussion section. + + You can either move a single work item from the **ellipses** and selecting the **Move to Team Project** option. This pops up the work item in the new project and prompts you to fix any area / iteration path validation errors before saving. + + You can additionally write a query for the work items you want to migrate and multi-select (using ctrl-a, ctrl-click, ctrl-shift-click, etc.), **right click**, and select **Move to Team Project...**. From there, you will be brought to a page + +![bulk move team project](/assets/screenshots/2021-09-29-azure-devops-migrate-work-items/bulk-move-team-project.png){: .shadow } +_Migrating multiple work items to a new project_ + +This method moves the work item in whole, preserving the original work item ID and history. If you move parent/child items (i.e.: Feature/User Story), these relationships are preserved as well. You even have the option to convert the items being moved to the new team project to a different work item type. + +There is [more documentation of this feature on this page](https://docs.microsoft.com/en-us/azure/devops/boards/backlogs/move-change-type?view=azure-devops). + +## Moving work items to a project to a different organization - no fidelity but easier + +When moving work items to a different organization, the task becomes a little harder. The easiest of the options is to use the Excel [Office Integration Tools](https://docs.microsoft.com/en-us/azure/devops/boards/backlogs/office/bulk-add-modify-work-items-excel?view=azure-devops&tabs=agile-process#import-work-items-flat-list) plugin (Windows-only) or [CSV Import/Export](https://docs.microsoft.com/en-us/azure/devops/boards/queries/import-work-items-from-csv?view=azure-devops) (cross-platform). It's relatively simple, but still a little more complex than just moving work items to a new project in the UI. + +There are two sub-options here, but both will require a query to be created. + +Assuming you want child/parent relationships migrated, create a [*tree* query](https://docs.microsoft.com/en-us/azure/devops/boards/queries/using-queries?view=azure-devops&tabs=browser#use-a-tree-of-work-items-to-view-hierarchies) that includes all of the columns of data that you want to export. Here is an example list of columns: +> + - Work item name + - Work item type + - Assigned to + - State + - Iteration Path + - Area path + - Tags + - Description (text formatting might change slightly) + - Acceptance Criteria (text formatting might change slightly) + - Remaining Work + - Effort + - Backlog Priority + - Priority + - Value Area + - Business Value + - Time Criticality + - Target Date + +Fields not migrated with this method: +> + - Any formatting in the description field should get exported as HTML, including pasted images, but the img src will still be the source project + - Original creator + - Date created + - Attachments + - History + - Other links in work items i.e.: related to, affected by, changeset, hyperlinks... + +### Excel Office Integration Tools +1. Create matching area paths / iteration paths +1. Create the query with the source work items and the columns/fields you want to export +1. Create the same query in the target project. I like to use the [Wiql Editor](https://marketplace.visualstudio.com/items?itemName=ottostreifel.wiql-editor) to be able to easily copy/paste the query to the target project, sort of like a copy/pasting a SQL query +1. Open up the source query in Excel, using the 'New List' button under the Team ribbon +1. Open up a new Excel sheet and load the target query. + - The target query should be empty; it just should have all of the columns in the same order + - This seems to work better in new instance of Excel vs. new tab in same sheet + - Use the 'Add Tree Level' button to create the same number of 'Title' columns that the source query has - for example, if migrating Epics, Features, User Stories, Tasks, you would have Title 1, Title 2, Title 3, Title 4 in your source query +1. Copy all of the content - except the work item ID - from the source query table into the target query table +1. Fix any 'Assigned To' names, Area Paths, Iteration Paths, etc. +1. Here's a slightly tricky part - the work items have to be saved as *New* (or *To Do* for a task with the Scrum template) + - Excel will show a validation error for work items that have a non-new state + - Replace each state with 'New' or the new equivalent +1. Assuming there are no more validation errors, 'Publish' the work items and wait (tip: I've found that any more than 1000 at a time and Excel will start to choke up) +1. Now that the work items are published, do not refresh! You want them in the same exact list as you've copied them in - you can then return to your original query and copy the entire state column and paste it on top of the state column in the target table +1. Publish again - the imported work items should have their original states now + +### Export/Import CSV +1. Create matching area paths / iteration paths +1. Create the query with the source work items and the columns/fields you want to export +1. Export the query as CSV - click the ellipses in the upper right and 'Export to CSV' +1. Open the CSV and remove all of the Work Item IDs from the Work Item ID column +1. Modify the CSV to change any fields that you want to ahead of time, such as area path / iteration paths +1. In the target project, go to Boards > Work Items - there is an 'Import Work Items' button +1. Import the CSV and resolve any validation errors + - Excel will show a validation error for work items that have a non-new state + - Replace each state with 'New' or the new equivalent (i.e.: 'To Do' for Tasks in the Scrum template) + - You can use the [Shift key select multiple work items](https://docs.microsoft.com/en-us/azure/devops/boards/backlogs/bulk-modify-work-items?view=azure-devops#to-multi-select-and-open-the-context-menu), right click, and edit these items in bulk +1. Once all the validation errors have been resolved, click the 'Save Items' button in the upper left +1. To put the original states back, from this same screen, click the 'Export to CSV' button - the CSV exported here with the new Work Item IDs should be in the same order as the query that was originally exported/imported. You can then return to the original CSV and copy the entire state column and paste it on top of the state column in the newly exported CSV +1. 'Import from CSV' the new CSV that has the new Work Item IDs and the original state +1. 'Save Items' again - the imported work items should have their original states now + +## Moving work items to a project to a different organization - full fidelity but harder + +There are a couple tools to do this, but the tool that I have the most experience in is the [nkdAgility/azure-devops-migration-tools](https://github.com/nkdAgility/azure-devops-migration-tools) tool. Microsoft has a tool, [Microsoft/vsts-work-item-migrator](https://github.com/Microsoft/vsts-work-item-migrator), but I have not used. + +If you are going to go down this route, I recommend checking out Martin's [video](https://www.youtube.com/watch?v=RCJsST0xBCE) about how this works and how you can configure the tool. Since the video was posted, the configuration and 'Processors' have changed slightly. Enabling and modifying the processor settings is how you configure the various components you want migrated. There is additional documentation for the tool on this [page](https://nkdagility.com/learn/azure-devops-migration-tools/), and more information specifically on the work item processor on this [page](https://nkdagility.com/learn/azure-devops-migration-tools/Reference/v1/Processors/WorkItemMigrationContext/). + +It's a great tool and it works really well, but one thing I found difficult when I got started was getting a sample configuration file to use. Now, I haven't used the migrator tool in a while, but I do have a v11 configuration file that I have used. Specifically, I have used [version 11.9.34](https://github.com/nkdAgility/azure-devops-migration-tools/releases/tag/v11.9.34). + +My sample configuration file is found in this [gist](https://gist.github.com/joshjohanning/baa2de38466302f0c173e0dccdd887c0). + +Search for the string `"Enabled":` and you will find the different processors that you can enable. In the sample configuration, the only enabled processor is the `WorkItemMigrationConfig` processor. You will need to modify the `WIQLQueryBit` to meet your needs. In the example highlighted below, the WIQL is going to migrate all User Stories, Tasks, Features, Epics, Bugs, and Test Cases - but not Test Suites and Test Plans (these are migrated in a the `TestPlansAndSuitesMigrationConfig` processor). If you were only migrating work items under an area path, you would add another clause, such as `AND [System.WorkItemType] UNDER "MyArea/Path"`. + +Example: + +```json + { + "$type": "WorkItemMigrationConfig", + "Enabled": true, + "ReplayRevisions": true, + "PrefixProjectToNodes": false, + "UpdateCreatedDate": true, + "UpdateCreatedBy": true, + "BuildFieldTable": false, + "AppendMigrationToolSignatureFooter": false, + "WIQLQueryBit": "AND [System.WorkItemType] IN ('User Story', 'Task', 'Feature', 'Epic', 'Bug', 'Test Case') AND [System.WorkItemType] NOT IN ('Test Suite', 'Test Plan')", + "WIQLOrderBit": "[System.ChangedDate] desc", + "LinkMigration": true, + "AttachmentMigration": true, + "AttachmentWorkingPath": "c:\\temp\\WorkItemAttachmentWorkingFolder\\", + "FixHtmlAttachmentLinks": false, + "SkipToFinalRevisedWorkItemType": true, + "WorkItemCreateRetryLimit": 5, + "FilterWorkItemsThatAlreadyExistInTarget": true, + "PauseAfterEachWorkItem": false, + "AttachmentMaxSize": 480000000, + "CollapseRevisions": false, + "LinkMigrationSaveEachAsAdded": false, + "GenerateMigrationComment": true, + "NodeBasePaths": [ + ], + "WorkItemIDs": null + } +``` + +### Run Migration + +Here is a high-level list of steps that you need to do in order to run the migration: +1. Add the `ReflectedWorkItemId` field to each of the work items you are migrating in both the source and target project. I believe you can get away without adding it to the source project, but then it makes it impossible to re-start a migration +1. Install the version you want + - `choco install vsts-sync-migrator` (oh yeah, this only runs on Windows only the last I checked) + - Or download from the [releases page](https://github.com/nkdAgility/azure-devops-migration-tools/releases); I have most recently used [version 11.9.34](https://github.com/nkdAgility/azure-devops-migration-tools/releases/tag/v11.9.34) +1. Set up your configuration file - my sample configuration file is found in this [gist](https://gist.github.com/joshjohanning/baa2de38466302f0c173e0dccdd887c0) +1. I usually run the Area Path processor (`TfsTeamSettingsProcessorOptions`, which references`TeamSettingsSource` and `TeamSettingsTarget`) by itself first to make sure the area path / iteration nodes are created. Decide if you want to enable `PrefixProjectToNodes`, where it will prefix the source project name in the area/iteration structure +1. Command to run migration and save the log to file: `migration.exe execute -c configuration.json > log.txt` + +The migrator has pretty good output logging, just search the output log file for `error` to work through any errors you have. + +**Pro-tip on running the migration**: If you can, run this inside a virtual machine inside of Azure so you have the best possible internet capabilities and don't have to worry about your computer falling asleep. + +**Old, bust maybe useful notes:** I have a lot of notes in an old [README.md](https://gist.github.com/joshjohanning/f43f90936b55f5fe00ea451edceb0579), but it's from v7.5 circa summer 2019, so it's probably not super relevant, but including here in case anyone can glean anything from it. There are some additional explanations on what the various setting are for the `WorkItemMigrationConfig` processor. I also have an old [configuration file from v8.9.x](https://gist.github.com/joshjohanning/e79ca39cf5b7819179a50699b3f65ea3) that might be helpful to some; I had a lot of success with it in July 2020. The `WorkItemMigrationConfig` processor docs for the older versions can be found [here](https://web.archive.org/web/20220523111946/https://nkdagility.github.io/azure-devops-migration-tools/Processors/WorkItemMigrationConfig.html). + +## Other Useful Scripts + +- [Azure DevOps - Create Iterations PowerShell Script](https://gist.github.com/joshjohanning/95118273cc3f117fb457521e8ed185b1) (but you should be able to use this for areas as well, just replace `/Iterations` with `/Areas`) + +## Conclusion + +Migrating work items can be easy but can easily be made complicated if things such as history and attachments are a requirement. I mean, who really looks at the work item's history anyway? I am a bigger fan of migrating the work items as is and leaving the work items in a read-only state in the source project that can be referred to for some time if history is needed. + +But obviously, sometimes this isn't possible, so that's why an article like this exists! I hope someone finds this helpful down the line. Feel free to leave any additional tips that you've found in the comments, or feel free to reach out for additional strategies you are considering. Good luck! diff --git a/_posts/2021-10-01-azure-frontdoor-preview-experience.md b/_posts/2021-10-01-azure-frontdoor-preview-experience.md new file mode 100644 index 0000000..02679b5 --- /dev/null +++ b/_posts/2021-10-01-azure-frontdoor-preview-experience.md @@ -0,0 +1,325 @@ +--- +title: 'Azure Front Door Standard/Premium Tips and Tricks' +author: Josh Johanning +date: 2021-10-01 16:30:00 -0500 +description: I share my experience, lessons-learned, and tips and tricks for working with the new Azure Front Door Standard/Premium SKUs +categories: [Azure, Front Door] +tags: [Azure, Azure Front Door] +image: + path: /assets/screenshots/2021-10-01-azure-frontdoor-preview-experience/front-door-overview-expanded.png + width: 500 # in pixels + height: 530 # in pixels + alt: Front Door Overview +--- + +## Update + +Since writing this article, Azure Front Door Standard/Premium has been released to GA (on March 29, 2022). You can read more about it here: [Introducing the new Azure Front Door: Reimagined for modern apps and content](https://azure.microsoft.com/en-us/blog/introducing-the-new-azure-front-door-reimagined-for-modern-apps-and-content/). + +Likewise, [Azure Front Door Terraform resources](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_endpoint) have since become available to help manage: + +- [**AzureRM v3.25.0**](https://github.com/hashicorp/terraform-provider-azurerm/releases/tag/v3.25.0) (Sept 29, 2022): Additions of [azurerm_cdn_frontdoor_route](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_route), [azurerm_cdn_frontdoor_custom_domain](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_custom_domain), and[azurerm_cdn_frontdoor_route_disable_link_to_default_domain](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_route_disable_link_to_default_domain) +- [**AzureRM v3.21.0**](https://github.com/hashicorp/terraform-provider-azurerm/releases/tag/v3.21.0) (Sept 2, 2022): Additions of [azurerm_cdn_frontdoor_rule](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_rule) and [azurerm_cdn_frontdoor_secret](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_secret) +- [**AzureRM v3.19.9**](https://github.com/hashicorp/terraform-provider-azurerm/releases/tag/v3.19.0) (Aug 18, 2022): Additions of [azurerm_cdn_frontdoor_firewall_policy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_firewall_policy) and [azurerm_cdn_frontdoor_security_policy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_security_policy) +- [**AzureRM v3.15.0**](https://github.com/hashicorp/terraform-provider-azurerm/releases/tag/v3.15.0) (July 21, 2022): Additions of [azurerm_cdn_frontdoor_origin](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_origin) and [azurerm_cdn_frontdoor_origin_group](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_origin_group) +- [**AzureRM v3.10.0**](https://github.com/hashicorp/terraform-provider-azurerm/releases/tag/v3.10.0) (June 10, 2022): Addition of [azurerm_cdn_frontdoor_rule_set](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_rule_set) +- [**AzureRM v3.9.0**](https://github.com/hashicorp/terraform-provider-azurerm/releases/tag/v3.9.0) (June 2, 2022): Additions of [cdn_frontdoor_profile](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_profile) (with options of `Standard_AzureFrontDoor` or `Premium_AzureFrontDoor`) and [azurerm_cdn_frontdoor_endpoint](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_endpoint) + +The bulk of this article should still be relevant, but if anything is strikingly wrong or has changed, leave a comment to let me know! + +## Overview + +I want to talk about Azure Front Door - not the old Azure Front Door - the new Azure Front Door, the new [PREVIEW Front Door with the Standard/Premium SKUs](https://docs.microsoft.com/en-us/azure/frontdoor/standard-premium/overview). But Josh, wait, this is a DevOps blog, why are you talking about Azure Front Door? Well, I had the ~~pleasure~~ experience of working with Front Door (Preview) on my most recent project, and thought I would be doing the world a disservice by not sharing a little bit of the ~~frustration~~ knowledge I have gained while working with it. Whether no one else is using this service or no one is talking about it, we struggled to find many resources online for how to do certain things in the Front Door (Preview), so this is where this article comes into play. + +I am not planning on writing an entire how-to article, this is just intended to serve as a resource that hopefully the SEO Gods can help make someone else's life easier. If you are unfamiliar with Azure Front Door (Preview), or want some official background and guidance from Microsoft, see the [What is Azure Front Door Standard/Premium (Preview)?](https://docs.microsoft.com/en-us/azure/frontdoor/standard-premium/overview) page. Also, this is a [great resource](https://www.kenmuse.com/blog/comparing-azure-front-door-to-other-services/) from Ken Muse on when you should use Azure Front Door vs. Application Gateway, Load Balancer, or Traffic Manager. + +Note: For the purposes of this article, I am going to abbreviate Azure Front Door as AFD, and when I say AFD, I mean the new Preview Azure Front Door; I will not be referring to the classic / old Front door here. + +(PS: Guinness Book of Records, see above for my submission on most times "Front Door" has been used in a single sentence) + +## Things That Work Well + +- Private Endpoints + - Azure Front Door does a great job of routing to services such as App Services, Function Apps, Storage Accounts, and Private Link Services that are protected via Private Endpoints + - Since these are 'magic' aka managed Private Endpoints, the Private Endpoint doesn't live in your subscription and you don't have access to it. Therefore, there doesn't seem to be a way to get the Origin Group / Origin deployment to automatically approve these, so you have to remember to go to the target resource and approve the Private Endpoint manually. This is similar to how Azure Data Factory's Private Endpoints work + - Private Endpoints only work with the Premium SKU +- Certificates! + - Azure Front Door does a great job of automatically managing certificates - including expirations - the default setting is to let Azure Front Door handle all of this for you with 0 configuration + - You still have the option to bring your own certificate by creating an Azure Front Door Secret linked to a Certificate in an Azure Key Vault - Azure Front Door even shows expiration of that certificate on the Domain page + - If you are using your own certificate, it needs to be in PFX / PKCS 12 format (not PEM) +- Web Application Firewall (WAF) + - Anecdotally, I only have experience with the Premium SKU of the Azure Front Door Preview service, but [creating a WAF tied to Microsoft-provided default rule set](https://docs.microsoft.com/en-us/azure/web-application-firewall/afds/waf-front-door-create-portal?toc=/azure/frontdoor/standard-premium/toc.json#default-rule-set-drs) is relatively simple + - The Standard SKU does not let you use a WAF +- Letting teams share a single Front Door resource + - Teams can manage their own Endpoints in a single Azure Front Door resource without worry of mucking it up too much for other teams + - This splits the [current $165/monthly cost](https://azure.microsoft.com/en-us/pricing/details/frontdoor/) for the Premium SKU +- Linking Origin to just about any hostname + - Works great for serving static websites hosted in an Azure Storage Account - we created a static website container `$web` and set our Origin to `Origin Type: Custom` and `Host Name: mystaticsite.z21.web.core.windows.net` + - We got the idea from [article as a basis for this static website setup](https://web.archive.org/web/20230329203146/https://docs.rackspace.com/blog/azure-front-door-storage-static-website/) (okay this is referencing the Classic Azure Front Door, but the custom host name [screenshot](https://stackoverflow.com/a/74105613/4270353) was what did the trick) + - If using Private Endpoints, you still have to have Azure Front Door create a private endpoint to the storage account. We set the 'target sub resource' (aka `groupId` in the ARM/API) to `web`. + - We did something similar with our Kubernetes linkage, creating a Private Link Service bound to the AKS managed subnet and Internal Load Balancer of our nginx service. In this pattern, the `Origin Type: Custom` and `Host Name` was set to the IP of the Internal Load Balancer and we used `null()` for our `Origin Host Header` and let nginx do header routing based on the URL that is being sent from Front Door to the Cluster. We set the 'target sub resource' (aka `groupId` in the ARM/API) to `null()`. See below [example](#arm-null-property-and-terraform) + - Works as expected with App Services and Function Apps; just set `Origin Type: App Services` + +## Things That Don't Work Well + +- Private Endpoints + - For about 3-4 weeks in July/early August, Azure Front Door's Private Endpoints just completely died and Microsoft support was super slow in getting any attention to this. I get that it's a Preview feature and things happen, but the resolution time was a little disappointing. We haven't had any issues since they "reverted" the change that broke this, though + - You have to manually approve the Private Endpoint on your target resource +- URL Rewriting + - We were trying to use a single Azure Front Door Endpoint to host all of our App Service APIs as Origins; sort of like a poor man's APIM (around 18x cheaper if you're not using all of the premium features of a Premium APIM) + - We wanted `myfd.z01.azurefd.net/foo` to redirect to `foo.azurewebsites.net` + - Maybe we were just doing it wrong, but we struggled to do native URL Rewriting in Azure Front Door - it's incredibly ~~possible~~ likely that we are just misinterpreting how it's supposed to work + - See my [Stack Overflow](https://stackoverflow.com/questions/68564910/url-rewrite-in-azure-front-door-preview-standard-premium) post of what we were trying to do, and someone's [suggestion](https://stackoverflow.com/a/68914412/4270353) on how to resolve (I have not had a chance to test yet) + - We instead did [URL Rewriting](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/url-rewriting?view=aspnetcore-5.0) by using middleware at the app level. It just requires a making a [small modification in the startup.cs file](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/url-rewriting?view=aspnetcore-5.0#url-redirect) (and if you're serving a Swagger page, there too!). See: [examples below](#url-rewrite) + - For Function Apps, there is no way to rewrite the incoming URL. However, you can edit the [host.json file and customize the base path](https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-output#hostjson-settings) by modifying the `routePrefix` property. See: [example below](#function-apps---hostjson) +- Update Times + - Okay minor gripe, but it takes anywhere from [5-20 minutes](https://docs.microsoft.com/en-us/azure/frontdoor/standard-premium/faq#how-long-does-it-take-to-deploy-an-azure-front-door-does-my-front-door-still-work-when-being-updated) for a change you make to Front Door to propagate down to you + - Sometimes loading in a different browser / using a proxy can help alleviate cache/dns issues +- [No native Terraform Resource (yet)](https://github.com/hashicorp/terraform-provider-azurerm/issues/11983) + - We are using the [azurerm_template_deployment](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/template_deployment) resource to deploy an ARM template within Terraform +- Editing via the UI + - You have to click the 'Edit' button on the Endpoint, then click into the Origin Group/Route to make changes - if you just click on the Origin Group/Route without clicking 'Edit' first, you will just be in a read only mode. +- WebSockets + - [Azure Front Door does not support WebSockets](https://github.com/MicrosoftDocs/architecture-center/issues/1891#issuecomment-918733057) - use Long Polling instead for SignalR use cases + +## Random notes + +- HTTPS Redirect in .NET Web Apps + - It is considered best practice to redirect http to https. This can be done in the code by adding the following to the `startup.cs`{: .filepath} file: `app.UseHttpsRedirection();` + - The problem with this method when using Azure Front Door with Private Endpoints is that this causes the app to redirect to the host (azurewebsites.net) instead of the incoming host URL (custom domain on Azure Front Door) + - To work around this, you should remove this line of code altogether from the application and let Front Door redirect traffic to HTTPS (a setting on the Route) + - If you're working with an Angular app, make sure to remove any HTTPS redirect from the `web.config`{: .filepath} +- Deleting Endpoints / Domains / Front Door + - If you want to delete a domain, you will need to clean up all of the associations (i.e.: the Route) + - You can delete the entire Endpoint the domain is associated to as well + - If there is/was a WAF associated to that endpoint, though, you need to manually go into the WAF resource and remove the association to the domain manually. This isn't made clear by the UI error (`Failed to delete the custom domain(s)`) or the CLI error (`(BadRequest) Property 'AfdDomainEntityKey.AfdDomainName' cannot be set to 'mysubdomain.mydomain.com'.`) + - If you go to re-create the Endpoint with the same name, note that it will fail for the first time with a `Conflict: That resource name isn't available` error message!!! You simply have to attempt to create the endpoint another time and then it will go through properly. The error will look something like this: `error": {\r\n "code": "Conflict",\r\n "message": "That resource name isn't available."` + - If you delete your entire Front Door, you still need to delete the WAF manually, and you will still see a `conflict` error message the first time you try to re-create a deleted endpoint +- `Our services aren’t available right now` error + - Make sure you have approved the Private Endpoint on the target resource + - Alternatively, go and edit the Origin Group > Origin, uncheck the Private Endpoint box, save the Origin, Save the Origin Group, wait 10-30 seconds for it to apply, edit the Origin Group > Origin, check the Private Endpoint box and select the right resource, save the Origin, save the Origin Group, and go and re-approve the Private Endpoint on the target resource +- `

Our services aren't available right now

We're working to restore all services as soon as possible.` error + - Your endpoint is still provisioning + - Or, your Route is misconfigured + - The HTML will be not be rendered on the page for this error - if it does render it means Front Door is routing correctly it's likely a problem with a private endpoint (see above) +- `Page not found` blue page error + - Wait 5-20 minutes for the Endpoint to provision +- Sometimes CORS errors disguise themselves as a misconfigured Endpoint / Private Endpoint +- Be familiar with [grabbing a Bearer token to interact directly with the Azure REST APIs](https://social.technet.microsoft.com/wiki/contents/articles/51140.azure-rest-management-api-the-quickest-way-to-get-your-bearer-token.aspx) + - As an example, you can plug that Bearer token into Postman and use this `GET` request to list the details about a Origin in an Origin Group: + ```terminal + GET https://management.azure.com/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/my-afd-rg/providers/Microsoft.Cdn/profiles/my-afd/originGroups/myorigingroup/origins?api-version=2020-09-01&Full + ``` + - This can also be helpful when deciphering what changes / values you need to use in an ARM template + + +## Summary + +Hopefully I haven't scared you away; Azure Front Door (Preview) has a lot of great features and shows a lot promise! And when it works, it works real well. Half of the frustration that we had with this service was that no one else had written about it, so we were kind of making it up as we go along. We have a solid foundation now, and once there is a native Terraform module, making changes for us will be even easier. + +Happy hacking! + +*See the [Appendix below](#appendix-examples) for miscellaneous [logging](#log-analytics--diagnostics-query), [URL rewriting](#url-rewrite), [AFD CLI](#afd-cli-commands), and [ARM template examples](#arm-null-property-and-terraform)* + +## Appendix: Examples + +### Log Analytics / Diagnostics Query + +This assumes you have a [Diagnostics Settings created](https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/diagnostic-settings?tabs=CMD) that captures `FrontDoorAccessLog` logs and sends to a Log Analytics resource. + +See the below for an example Log Analytics / Diagnostics query to find non-200 HTTP Status Codes + +``` +AzureDiagnostics + | where httpStatusCode_s != 200 + and TimeGenerated > ago(500m) +``` + +### URL Rewrite + +See the below sections for examples on how to do URL Rewriting in .NET (.NET Core) App Services and Function Apps + +#### .NET - startup.cs + +```csharp +using Microsoft.AspNetCore.Rewrite; // add this using + +public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) +{ + // url rewrite + var options = new RewriteOptions().AddRewrite(@"^myapi/(.*)", "$1", + skipRemainingRules: true); + app.UseRewriter(options); + + ... // remainder of code below +} +``` + +> Note: This makes the URL for local development something like `http://localhost:5001/myapi/...` + +> Note: If you need `/path` and `/path/` to work, then the regex for the rewrite should be `@"^myapi[/]?(.*)"` + +#### .NET - Swagger + +If you are using Swagger for an API, you should also update the prefix for the Swagger endpoint: + +```csharp + +// Enable middleware to serve generated Swagger as a JSON endpoint. +app.UseSwagger(); + +app.UseSwaggerUI(c => +{ + // Specify swagger JSON endpoint + var prefix = apiPath == "" ? "" : $"/{apiPath}"; + c.SwaggerEndpoint($"{prefix}/swagger/{apiVersion}/swagger.json", apiDefinitionTitle); + c.DocExpansion(DocExpansion.None); + // specifying the Swagger-ui endpoint. + c.RoutePrefix = "swagger-ui"; + c.DefaultModelsExpandDepth(-1); +}); +``` + +#### Function Apps - host.json + +`host.json`{: .filepath}: +```json +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "function1" + } + } +``` +{: file='host.json'} + +### AFD CLI Commands + +#### Delete Origin Group + +```terminal +az afd origin-group delete --profile-name my-afd --resource-group my-afd-rg --origin-group-name myorigingroup --yes +``` + +#### Delete Custom Domain + +```terminal +az afd custom-domain delete --profile-name my-afd --resource-group my-afd-rg --custom-domain-name mysubdomain.mydomain.net +``` + +#### Delete Route + +```terminal +az afd route delete --profile-name my-afd --resource-group my-afd-rg --endpoint-name my-endpoint --route default +``` + +#### Delete Endpoint + +```terminal +az afd endpoint delete --profile-name my-afd --resource-group my-afd-rg --endpoint-name my-endpoint +``` + +#### Show Endpoint + +```terminal +az afd endpoint show --profile-name my-afd --resource-group my-afd-rg --endpoint-name my-endpoint +``` + +#### Adding Custom Domain with Certificate + +Note that when `--custom-domain-name` asks for a name, it's just the friendly name of the domain as it appears in AFD + +```bash +az afd custom-domain create -my-afd-rg --custom-domain-name foobar --profile-my-afd --host-name '*.mysubdomain.mydomain.com' --minimum-tls-version TLS12 --certificate-type CustomerCertificate --secret my-wildcard-cert-pfx --debug +``` + +#### Purging Cache + +```bash +az afd endpoint purge --ids "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/my-afd-rg/providers/Microsoft.Cdn/profiles/my-afd/afdendpoints/my-endpoint" --content-paths "/*" +``` + +### ARM Null() property and Terraform + +We couldn't figure out a way to pass in `null()` from Terraform to our ARM template using the [azurerm_template_deployment](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/template_deployment) resource, and passing in `""` failed in mysterious ways or ended up just hanging the deployment. Essentially, we wrote some logic to convert the `""` passed into the template parameter to `null()` for us. + +Note lines 20, 25, and 69 for the relevant logic: + +```json +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "profileName": { + "type": "string" + }, + "originGroupsName":{ + "type": "string" + }, + "hostName":{ + "type": "string" + }, + "hostHeader":{ + "type": "string" + }, + "privateLinkResourceId":{ + "type": "string" + }, + "privateLinkResourceType":{ + "type": "string" + } + }, + "variables": { + "is-private-link-service-type": "[equals('', parameters('privateLinkResourceType'))]", + "is-null-host-header": "[equals('', parameters('hostHeader'))]" + }, + "resources": [ + { + "type": "Microsoft.Cdn/profiles/originGroups", + "apiVersion": "2020-09-01", + "name": "[concat(parameters('profileName'), '/', parameters('originGroupsName'))]", + "properties": { + "loadBalancingSettings": { + "sampleSize": 4, + "successfulSamplesRequired": 3, + "additionalLatencyInMilliseconds": 50 + }, + "healthProbeSettings": { + "probePath": "/", + "probeRequestType": "HEAD", + "probeProtocol": "Http", + "probeIntervalInSeconds": 100 + }, + "sessionAffinityState": "Disabled" + } + }, + + { + "type": "Microsoft.Cdn/profiles/originGroups/origins", + "apiVersion": "2020-09-01", + "name": "[concat(parameters('profileName'), '/', parameters('originGroupsName'), '/default')]", + "dependsOn": [ + "[resourceId('Microsoft.Cdn/profiles/originGroups', parameters('profileName'), parameters('originGroupsName'))]" + ], + "properties": { + "hostName": "[parameters('hostName')]", + "originHostHeader": "[if(variables('is-null-host-header'), null(), parameters('hostHeader'))]", + "httpPort": 80, + "httpsPort": 443, + "priority": 1, + "weight": 1000, + "enabledState": "Enabled", + "sharedPrivateLinkResource": { + "privateLinkLocation": "[resourceGroup().location]", + "privateLink": { + "id": "[parameters('privateLinkResourceId')]" + }, + "groupId": "[if(variables('is-private-link-service-type'), null(), parameters('privateLinkResourceType'))]", + "requestMessage": "Private link service from AFD" + } + } + } + ] +} +``` diff --git a/_posts/2021-11-14-azdo-angular-pipeline-caching.md b/_posts/2021-11-14-azdo-angular-pipeline-caching.md new file mode 100644 index 0000000..b05bf82 --- /dev/null +++ b/_posts/2021-11-14-azdo-angular-pipeline-caching.md @@ -0,0 +1,96 @@ +--- +title: 'Working Azure DevOps Pipeline Caching for Angular CI' +author: Josh Johanning +date: 2021-11-14 20:30:00 -0600 +description: I share how I finally got the Pipeline Cache task to work with my Angular build pipeline +categories: [Azure DevOps, Pipelines] +tags: [Azure DevOps, Azure Pipelines, Angular] +image: + path: /assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/load-npm-cache.png + width: 1212 # in pixels + height: 614 # in pixels + alt: Azure DevOps Pipeline Cache Task in action +--- + +## Overview + +I've tried several times to implement the [Pipeline Cache task using Microsoft's documentation](https://docs.microsoft.com/en-us/azure/devops/pipelines/release/caching?view=azure-devops#nodejsnpm), but have failed every time. I seemed to configure everything like the documentation indicates for my Node.js/npm (Angular) build, but the results are very inconclusive - I didn't really be saving any CI time. + +For builds where the `npm install` takes 30-60 seconds...it's not really a problem. However, recently I was working with a team where the `npm install` was taking 10 (!!!) minutes. This was not going to work for me, and for my sanity, I had to get this pipeline caching figured out. + +![slow npm install](/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/slow-npm-install.png ){: width="500" }{: .shadow } +_npm install taking 10 minutes to run_ + +## How To - The Explanation + +I was finally able to figure out what I was missing, part in thanks to [this post](https://dev.to/rupeshtiwari/caching-azure-ci-pipeline-artifacts-3085) - in particular, their screenshot: + +![pipeline cache task configuration from High Performance Programmer](/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/CacheBuildStep.png ){: width="600" }{: .shadow } +_Cache task configuration in a Classic Build Definition_ + +Note how this differs from [Microsoft's documentation](https://docs.microsoft.com/en-us/azure/devops/pipelines/release/caching?view=azure-devops#nodejsnpm): +```yml +variables: + npm_config_cache: $(Pipeline.Workspace)/.npm + +steps: +- task: Cache@2 + inputs: + key: 'npm | "$(Agent.OS)" | package-lock.json' + restoreKeys: | + npm | "$(Agent.OS)" + path: $(npm_config_cache) + displayName: Cache npm +``` + +Notice how the first screenshot is caching the `$(Build.SourcesDirectory)/Project/node_modules`{: .filepath} folder vs Microsoft's code sample is caching `$(Pipeline.Workspace)/.npm`{: .filepath} - quite a critical difference! It makes sense after thinking about it, when you run `npm install` locally, where is it going to download all of the modules to? The `node_modules`{: .filepath} folder in the root of the project, of course. + +The way the task works is it zips up and saves the `path` you specify and stores it to the build (as a build-in post-build step). During the next build, if the `key` matches, it downloads the zip and extracts it to the aforementioned `path`. Both of the above examples use the `key: npm | “$(Agent.OS)” | $(Build.SourcesDirectory)/Project/package-lock.json`{: .filepath}, where it matches the OS the build is running on as well as the hashed content of the `package-lock.json`{: .filepath} file. + +This means, that if you flip a build from Windows to Ubuntu, the key won't match, and the contents of the cache won't be restored. Likewise, if the hash of the `package-lock.json`{: .filepath} file changes (ie: you add a package, change a package version, remove a package, etc.), the cache won't be restored. In both cases, you would expect a full `npm install` from scratch. If the build completes successfully, you should expect a new cache to be uploaded as an automatically added post build step: +![uploading cache to pipeline](/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/upload-cache.png ){: .shadow } +_Uploading of the cache as a post-job step_ + +## How To - Just the YAML + +Putting it all together, here's what my task looks like: + +```yml +- task: Cache@2 + displayName: load npm cache + inputs: + key: npm | $(Agent.OS) | $(Build.SourcesDirectory)/Source/MyWeb/package.json + restoreKeys: | + npm | "$(Agent.OS)" + path: $(Build.SourcesDirectory)/Source/MyWeb/node_modules + cacheHitVar: CACHE_HIT +``` + +- Note: You'll notice my example is using `package.json`{: .filepath} and not `package-lock.json`{: .filepath} - the team I was working with wasn't using the `package-lock.json`{: .filepath} file, so I just wanted to illustrate that you can also use the `package.json`{: .filepath} as a key and it will work just as well +- Note: The `cacheHitVar` set to `CACHE_HIT` will evaluate to `true` if the cache hit is a success - could be useful for a [conditional task](https://docs.microsoft.com/en-us/azure/devops/pipelines/release/caching?view=azure-devops#conditioning-on-cache-restoration) where maybe you don't even run the `npm install` command at all + +## Gotchas + +- There's no way to delete a cache once it's stored in the pipeline - you can simply change the `key:` property by adding another string literal - see the below example: + ```yml + # from: + key: npm | $(Agent.OS) | $(Build.SourcesDirectory)/Source/MyWeb/package.json + # to: + key: npm | node_modules | $(Agent.OS) | $(Build.SourcesDirectory)/Source/MyWeb/package.json + ``` + +- Branches - the caches are isolated between branches - meaning that if I create a feature branch off main, I won't be able to use main's cache - [more info on this here](https://docs.microsoft.com/en-us/azure/devops/pipelines/release/caching?view=azure-devops#cache-isolation-and-security) +- Pull request runs do not write cache to the source or target branch, only the intermediate branch such as `refs/pull/1/merge` - [more info on this here](https://docs.microsoft.com/en-us/azure/devops/pipelines/release/caching?view=azure-devops#cache-isolation-and-security) +- Expiration - the cache expires after 7 days of no activity (hint - create a [scheduled build](https://docs.microsoft.com/en-us/azure/devops/pipelines/process/scheduled-triggers?view=azure-devops&tabs=yaml) if you want to ensure that a poor soul doesn't have to experience a 20 minute build on Monday morning) + +I also like this [Medium post from Dev Shah](https://medium.com/tenets/azure-pipeline-caching-a53e8117c242) for additional gotchas. + +## Summary + +Before working with us, the team's build averaged 30 minutes. Using a slimmed down build job and running using hosted agents brought us from 30 minutes to 20 minutes. + +Since we have added and properly configured the Pipeline Cache task in our Angular CI build, we have shaved off **10 minutes** from each build. When our `npm build` alone takes 10 minutes, our average build time of 20 minutes has been reduced by 50% to 10 minutes: +![build time comparison](/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/build-time-comparison.png ){: .shadow } +_Average build time of 20 minutes shaved down to 10 minutes after adding the Cache task_ + +This was a huge win for the us and dev team, and I'm happy to say the third time's the charm for me on trying to configure the Azure DevOps Pipeline Caching Task. diff --git a/_posts/2021-11-23-github-codespaces-powerlevel10k.md b/_posts/2021-11-23-github-codespaces-powerlevel10k.md new file mode 100644 index 0000000..c931fca --- /dev/null +++ b/_posts/2021-11-23-github-codespaces-powerlevel10k.md @@ -0,0 +1,207 @@ +--- +title: 'Powerlevel10k Zsh Theme in GitHub Codespaces' +author: Josh Johanning +date: 2021-11-23 16:00:00 -0600 +description: How to use the Powerlevel10k Zsh theme in GitHub Codespaces +categories: [GitHub, Codespaces] +tags: [GitHub, GitHub Codespaces, VS Code, Development Environment] +media_subpath: /assets/screenshots/2021-11-23-github-codespaces-powerlevel10k +image: + path: codespace.png + width: 1123 # in pixels + height: 606 # in pixels + alt: A GitHub Codespace with the Powerlevel10k Zsh theme +--- + +## Overview + +Hello 👋 ! This is my first post since joining the GitHub FastTrack team last week. I'm still learning a lot of information as well as tips and tricks from other Hubbers. One of the things I have started playing around more with now is [GitHub Codespaces](https://github.com/features/codespaces). I wanted to have my GitHub Codespace to have the exact same look and feel that my local environment had - including my Zsh plugins and Zsh theme: [Powerlevel10k](https://github.com/romkatv/powerlevel10k). I found a [post from Burke Holland](https://burkeholland.github.io/posts/codespaces-dotfiles/) that got me close, but it didn't have the Powerlevel10k bit in it. + +> If you are interested to seeing my local development environment setup, see: [My macOS Development Environment: iTerm2, oh-my-zsh, and VS Code](/posts/my-macos-development-environment/) +{: .prompt-tip } + +## What is GitHub Codespaces? + +I'll try not to belabor the point, but [GitHub Codespaces](https://github.com/features/codespaces) is a convenient way for teams to build a consistent development environment baseline that everyone can tap into. Gone are the days where the amount of time spent setting up a new development environment when switching teams or receiving a new laptop is measured in DAYS. I could use a machine (or iPad!) anywhere in the world, and if I connected to my Codespace, I could start development immediately. + +By default, Codespaces is instantiated with a base Ubuntu image that has a [few languages and runtimes pre-installed](https://docs.github.com/en/codespaces/customizing-your-codespace/configuring-codespaces-for-your-project#using-the-default-configuration). To further customize the experience, a [development container can be created](https://docs.github.com/en/codespaces/customizing-your-codespace/configuring-codespaces-for-your-project#using-a-predefined-container-configuration) that has all of the prerequisites installed, the proper versions of those prerequisites, and anything else that a team might need in order to compile/test the code. The concept of a development container (aka dev container) is not necessarily new; you can use a [development container in your local instance of VS Code](https://code.visualstudio.com/docs/remote/create-dev-container) with Docker ([more info on using dev container here](https://code.visualstudio.com/docs/remote/containers)). What is new, though, is running this directly in your browser! + +Yes you read right - right in your browser! A compute instance powers the developer's environment, allowing for all development through a virtualized VS Code window in the browser! You can optionally connect your Codespace to your local VS Code if desired. There's a toggle on the [GitHub Codespaces](https://github.com/features/codespaces) main page that lets you see how the Codespace would look in the browser vs. desktop - and they are identical*. + +*\* if you have the proper configuration setup and synced as mentioned in this post ;)* + +## Setup VS Code Settings Sync + +Before we configure Powerlevel10k, we need to make sure we set up [VS Code settings sync](https://code.visualstudio.com/docs/editor/settings-sync). Even before I started at GitHub, I used my GitHub account to sync my settings. You could alternatively use a Microsoft account, but I think it makes more sense in this case to use a GitHub account since we will be launching a GitHub Codespace. + +One of the things we need to make sure this is setup for is for the Terminal font that I have defined for the Powerlevel10k theme (`MesloLGS NF`), but you'd want your other VS Code settings to sync as well. + +After firing up your Codespace, it should automatically sign you in and sync your settings and extensions, but if not, [sign in manually](https://code.visualstudio.com/docs/editor/settings-sync#_turning-on-settings-sync). + +## Configure Powerlevel10k + +There are a few steps: + +### 1. Create a dotfiles repository + +Now, we need to create a [dotfiles repository](https://docs.github.com/en/codespaces/customizing-your-codespace/personalizing-codespaces-for-your-account#dotfiles) - and it needs to be public. GitHub knows to use the `dotfiles` repository created under your username. For example, here is my [dotfiles repository](https://github.com/joshjohanning/dotfiles). + +**Bonus**: I've cloned this repository locally and created a `symlink` from `~/.zshrc`{: .filepath} to `~/dotfiles/.zshrc`{: .filepath}. I followed [this article](https://www.freecodecamp.org/news/dotfiles-what-is-a-dot-file-and-how-to-create-it-in-mac-and-linux/), but I know others who have used the [dotbot](https://github.com/anishathalye/dotbot) tool. + +The steps can be summarized by: + +```shell +git clone https://github.com/joshjohanning/dotfiles ~/dotfiles +mv ~/.zshrc ~/.zshrc/dotfiles +ln -s ~/dotfiles/.zshrc ~/.zshrc +``` +{: .nolineno} + +### 2. Add your .zshrc and .p10k.zsh to your dotfiles repository + +Add in your `.zshrc`{: .filepath} and `.p10k.zsh`{: .filepath} files to this repository! + +My [.zshrc](https://github.com/joshjohanning/dotfiles/blob/main/.zshrc) and [.p10k.zsh](https://github.com/joshjohanning/dotfiles/blob/main/.p10k.zsh) are linked, respectively. + +If you followed something similar to the symlink example above, adding your `.zshrc`{: .filepath} and `.p10k.zsh`{: .filepath} file could be as simple as doing: `git add .; git commit -m "adding dotfiles"; git push` + +### 3. Update your .zshrc file + +You're `.zshrc`{: .filepath} likely hard codes your local user directory for the oh-my-zsh installation. Update it as such: + +```shell +# Path to your oh-my-zsh installation. +export ZSH="/home/joshjohanning/.oh-my-zsh" +``` +{: .nolineno} + +```shell +# Path to your oh-my-zsh installation. +export ZSH="${HOME}/.oh-my-zsh" +``` +{: .nolineno} + +### 4. Create an install.sh file to install Zsh theme and plugins + +Now, we need to make sure our Powerlevel10k theme and Zsh plugins are installed when the Codespace is initialized. + +My [install.sh](https://github.com/joshjohanning/dotfiles/blob/main/install.sh) script that I use that includes the Powerlevel10k setup is below: + +```shell +#!/bin/sh + +zshrc() { + echo "===========================================================" + echo " cloning zsh-autosuggestions " + echo "-----------------------------------------------------------" + git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions + echo "===========================================================" + echo " cloning zsh-syntax-highlighting " + echo "-----------------------------------------------------------" + git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting + echo "===========================================================" + echo " cloning powerlevel10k " + echo "-----------------------------------------------------------" + git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k + echo "===========================================================" + echo " import zshrc " + echo "-----------------------------------------------------------" + cat .zshrc > $HOME/.zshrc + echo "===========================================================" + echo " import powerlevel10k " + echo "-----------------------------------------------------------" + cat .p10k.zsh > $HOME/.p10k.zsh +} + +# change time zone +sudo ln -fs /usr/share/zoneinfo/America/Chicago /etc/localtime +sudo dpkg-reconfigure --frontend noninteractive tzdata + +zshrc + +# make directly highlighting readable - needs to be after zshrc line +echo "" >> ~/.zshrc +echo "# remove ls and directory completion highlight color" >> ~/.zshrc +echo "_ls_colors=':ow=01;33'" >> ~/.zshrc +echo 'zstyle ":completion:*:default" list-colors "${(s.:.)_ls_colors}"' >> ~/.zshrc +echo 'LS_COLORS+=$_ls_colors' >> ~/.zshrc +``` +{: file='install.sh'} + +* The `cat .zshrc > $HOME/.zshrc` and `cat .p10k.zsh > $HOME/.p10k.zsh` lines here are pretty important - this is what takes the content you have in your dotfiles repository and move it to the `$HOME` directory of the Codespace. +* I also wanted the machine to have my local time zone. Whenever I would commit, I would see UTC time in my `git log`. The GitHub UI translates this just fine, but my Jekyll blog theme uses the `git commit` timestamp when displaying the updated timestamp on the blog post, which I did not like since it was inconsistent with the posted time zone (where I'm using Central US). +* The `zshrc` line launches the `zsh` command prompt when I launch my CodeSpace instead of `bash`. +* And finally, when I would `ls` or use the directory autocomplete suggestions, I would see a [lime green blackground with blue text on directories](https://stackoverflow.com/questions/64250199/how-to-change-color-of-directory-suggestions-in-zsh/70598500#70598500) which was unreadable. These lines remove the highlighting and simply use a distinct color for the directories instead: + ![Default zsh configuration - directory names are hard to read](directory-bad.png){: .shadow } + _Directories are unreadable with default zsh configuration_ + + ![With modifications - easier to read directory names](directory-good.png){: .shadow } + _Directories readable again!_ + +Important: Don't `git add` this just yet! [Continue to the next step](#5-mark-the-install-script-as-executable-with-git). + +*This `install.sh`{: .filepath} script is based on this [post](https://burkeholland.github.io/posts/codespaces-dotfiles/).* + +### 5. Mark the install script as executable with git + +Kind of annoying, but if you don't do this, you'll notice that in your Codespaces creation logs that the install.sh script is not executable: + +```shell +git add install.sh --chmod=+x +``` +{: .nolineno} + +Note: After you run this command, you still might see that the `install.sh`{: .filepath} file has a change that wants to be added/committed (viewed in the Source Control window in VS Code or with `git status`). Ignore or discard those changes (`git restore install.sh`). + +If you've already added it, you can remove it and re-add it with: + +```shell +git rm --cached install.sh +git add install.sh --chmod=+x +``` +{: .nolineno} + +There's an alternative command you can run to mark the file as executable in-place with `git update-index --chmod=+x install.sh`, but if you do that, every time you change the file the executable bit will get flipped off and you’ll have to run that command again. Inevitably, you will forget, and your Codespace's Zsh environment will be broken. + +You can view the Codespaces creation logs by opening the command palette (`CMD`/`CTRL` + `Shift` + `P`) and typing `> Codespaces: View Creation Log` + +### 6. Link your dotfiles repo to Codespaces + +Go to your [GitHub Codespaces settings](https://github.com/settings/codespaces) and make sure the 'Automatically install dotfiles' box is checked. + +### 7. Set zsh as the default terminal in Codespaces + +By default, Codespaces will open up a `bash` terminal window. We just did all of this work to pimp out our Codespace, we should make sure it loads the `zsh` terminal by default instead. Add this line to your VS Code `settings.json`{: .filepath} file by opening the command palette (`CMD`/`CTRL` + `Shift` + `P`) and typing `> Preferences: Open Settings (JSON)` : + +```json +"terminal.integrated.defaultProfile.linux": "zsh" +``` + +This is an extended snippet of the relevant section in my VS Code's `settings.json`{: .filepath} : + +```json + "terminal.integrated.shell.osx": "/bin/zsh", + "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.fontFamily": "MesloLGS NF", +``` + +Note the font configuration! + +### 8. Log into VS Code Settings Sync in the Codespaces + +After firing up your Codespace, sign into [VS Code Settings sync](https://code.visualstudio.com/docs/editor/settings-sync#_turning-on-settings-sync) (I use GitHub_) + +## Gotchas + +- Fonts - I was lucky as it seems that whatever configuration I had in my `.p10k.zsh`{: .filepath} file and my font choice for my VS Code terminal (`MesloLGS NF`) seemed to work out of the box - but I could imagine some headache if you used a more custom font. You can [selectively *not* sync certain settings](https://code.visualstudio.com/docs/editor/settings-sync#_configuring-synced-data), so if you want a more default font to be used in your Codespace and a custom font to be used locally, you could probably do so. +- If you are using the [Brave Browser](https://brave.com/), the shield (AdBlock) functionality tends to show a degraded view of the terminal window. Flip that shield off for this site. Annoyingly, you have to do this for each Codespace you create, as there is not the ability to whitelist a subdomain - but there is a [GitHub issue made for it](https://github.com/brave/brave-browser/issues/5290). Full error with shields on for those interested: + > Error loading webview: Error: Could not register service workers: NotSupportedError: Failed to register a ServiceWorker for scope ('https://1c1b9171-108f-4374-9efc-20593a07163b.vscode-webview.net/stable/ccbaa2d27e38e5afa3e5c21c1c7bef4657064247/out/vs/workbench/contrib/webview/browser/pre/') with script ('https://1c1b9171-108f-4374-9efc-20593a07163b.vscode-webview.net/stable/ccbaa2d27e38e5afa3e5c21c1c7bef4657064247/out/vs/workbench/contrib/webview/browser/pre/service-worker.js?id=1c1b9171-108f-4374-9efc-20593a07163b&swVersion=2&extensionId=vscode.markdown-language-features&platform=browser&vscode-resource-base-authority=vscode-resource.vscode-webview.net&parentOrigin=https%3A%2F%2Fjoshjohanning-pipeline-templates-6497vrprh5r7v.github.dev'): The user denied permission to use Service Worker.. + + +## Summary + +Take a look at our awesome development environment, all running from within our browser! +![GitHub Codespaces using Powerlevel10k Zsh Theme](codespace.png ){: .shadow } +_Powerlevel10k ZSH theme in GitHub Codespaces_ + +And yes...I did write and test this blog post completely with GitHub Codespaces :). diff --git a/_posts/2021-12-03-github-advanced-security-feature-chart.md b/_posts/2021-12-03-github-advanced-security-feature-chart.md new file mode 100644 index 0000000..d281278 --- /dev/null +++ b/_posts/2021-12-03-github-advanced-security-feature-chart.md @@ -0,0 +1,80 @@ +--- +title: 'GitHub Advanced Security Feature Comparison' +author: Josh Johanning +date: 2021-12-03 16:30:00 -0600 +description: A feature comparison between GitHub Enterprise, GitHub Enterprise with GitHub Advanced Security (GHAS), and Public Repos on github.com +categories: [GitHub, Advanced Security] +tags: [GitHub, GitHub Advanced Security, Dependabot] +media_subpath: /assets/screenshots/2022-03-08-github-advanced-security-permissions-chart +image: + path: ../2023-02-28-security-alerts/security-overview-light.png + width: 100% + height: 100% + alt: Security Overview for an Organization +pin: false +--- + +## Overview + +[GitHub Advanced Security (GHAS)](https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security) is an addon for those on GitHub Enterprise. While it costs extra, the [code scanning](https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/about-code-scanning), [secret scanning](https://docs.github.com/en/github/administering-a-repository/about-secret-scanning), and the [dependency review](https://docs.github.com/en/code-security/supply-chain-security/about-dependency-review) feature set is quite impressive. Nearly all of these features are enabled by default for Public Repos hosted on github.com (with the exception of the [security overview](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-overview/about-the-security-overview), [push protections for secrets](https://docs.github.com/en/enterprise-cloud@latest/code-security/secret-scanning/protecting-pushes-with-secret-scanning), and [custom patterns for secret scanning](https://docs.github.com/en/enterprise-cloud@latest/code-security/secret-scanning/defining-custom-patterns-for-secret-scanning)), so you can easily create a repo with some [sample code](https://github.com/joshjohanning/ghas-demo) from your personal GitHub account to play around with the features. + +Follow updates in the [Changelog blog](https://github.blog/changelog/label/advanced-security/) for the latest updates on GitHub Advanced Security! + +See also: [GitHub Advanced Security Permissions Chart](/posts/github-advanced-security-permissions-chart/) + +## GitHub Advanced Security Feature Comparison + +I made this chart a while back for a client when helping them determine if the GHAS addon was worth it to them: + +| Feature | GHE | GHE + GHAS | Public Repos | +|---------|:----:|:-----------:|:------------:| +| [Dependency Graph](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-the-dependency-graph) | ✔️ | ✔️ | ✔️ | +| [Dependabot Alerts for Vulnerable Dependencies](https://docs.github.com/en/code-security/supply-chain-security/managing-vulnerabilities-in-your-projects-dependencies/about-alerts-for-vulnerable-dependencies) | ✔️ | ✔️ | ✔️ | +| [Dependabot Security Updates (PRs for vulnerabilities)](https://docs.github.com/en/code-security/supply-chain-security/managing-vulnerabilities-in-your-projects-dependencies/about-dependabot-security-updates) | ✔️ | ✔️ | ✔️ | +| [Dependabot Version Updates (PRs for package updates)](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/about-dependabot-version-updates) | ✔️ | ✔️ | ✔️ | +| [Generate SBOM](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/exporting-a-software-bill-of-materials-for-your-repository) | ✔️ | ✔️ | ✔️ | +| [GitHub Security Advisories](https://docs.github.com/en/code-security/security-advisories/about-github-security-advisories) | ✔️ | ✔️ | ✔️ | +| [Security Policies](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository) | ✔️ | ✔️ | ✔️ | +| [Security Overview for the Org](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-overview/about-the-security-overview) | ✔️ | ✔️ | ✔️ | +| [Security Overview for the Enterprise (Beta)](https://github.blog/changelog/2022-03-01-security-overview-for-enterprise-in-beta/) | ✔️ | ✔️ | ✔️ | +| [CodeQL Code Scanning](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning) | | ✔️ | ✔️ | +| [Dependency Review in Pull Request (rich diff)](https://github.blog/changelog/2021-10-05-dependency-review-is-generally-available/) | | ✔️ | ✔️ | +| [Dependency Review Action (Beta)](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement) | | ✔️ | ✔️ | +| [Secret Scanning](https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning) | | ✔️ | ✔️ | +| [Secret Scanning - Custom Patterns](https://docs.github.com/en/enterprise-cloud@latest/code-security/secret-scanning/defining-custom-patterns-for-secret-scanning) | | ✔️ | | +| [Secret Scanning - Push Protections (Beta)](https://docs.github.com/en/enterprise-cloud@latest/code-security/secret-scanning/protecting-pushes-with-secret-scanning) | | ✔️ | | + +Notes: +- GHE = GitHub Enterprise +- GHAS = GitHub Advanced Security +- This chart primarily focuses on **GitHub Enterprise Cloud**, but note that Advanced Security is available for GitHub Enterprise Server [3.0 or higher](https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security). There may be slight differences in the features available for GitHub Enterprise Server based on the version + +## About Dependabot + +There are a few components of Dependabot, and while I tried to list each feature individually in the chart, I wanted to call out a [helpful quote of the documentation](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/about-dependabot-version-updates) to help describe part of the differences between [version updates](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/about-dependabot-version-updates) and [security updates](https://docs.github.com/en/code-security/supply-chain-security/managing-vulnerabilities-in-your-projects-dependencies/about-dependabot-security-updates): + +> About Dependabot version updates: +> +> When Dependabot identifies an outdated dependency, it raises a pull request to update the manifest to the latest version of the dependency. For vendored dependencies, Dependabot raises a pull request to replace the outdated dependency with the new version directly. You check that your tests pass, review the changelog and release notes included in the pull request summary, and then merge it. For more information, see "[Enabling and disabling Dependabot version updates](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/enabling-and-disabling-dependabot-version-updates)." +> +> If you enable security updates, Dependabot also raises pull requests to update vulnerable dependencies. For more information, see "[About Dependabot security updates](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates)." +> +> When Dependabot raises pull requests, these pull requests could be for security or version updates: +> - _[Dependabot security updates](https://docs.github.com/en/code-security/supply-chain-security/managing-vulnerabilities-in-your-projects-dependencies/about-dependabot-security-updates)_ are automated pull requests that help you update dependencies with known vulnerabilities. +> - _[Dependabot version updates](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/about-dependabot-version-updates)_ are automated pull requests that keep your dependencies updated, even when they don’t have any vulnerabilities. To check the status of version updates, navigate to the Insights tab of your repository, then Dependency Graph, and Dependabot. + +**Dependabot version updates** requires creating a [`dependabot.yml`{: .filepath}](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#configuration-options-for-private-registries) configuration file in your repository whereas **Dependabot security updates** automatically locates [supported package manifest files](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-the-dependency-graph#supported-package-ecosystems) and alerts you when it contains vulnerable dependencies. + +[Dependabot version updates](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/about-dependabot-version-updates#supported-repositories-and-ecosystems) supported package ecosystems differs from that of [Dependabot security updates](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-the-dependency-graph#supported-package-ecosystems). + +## Changelog + +| Date | Note | +|-------------|------| +| Apr 26 2023 | Removing subscript note on [secret scanning for public repos](https://github.blog/2023-02-28-secret-scanning-alerts-are-now-available-and-free-for-all-public-repositories/), added SBOM generation +| Oct 11 2022 | Removing Beta from [Security Overview for the Org](https://github.blog/changelog/2022-04-07-security-overview-for-organizations-is-generally-available/),
[Security Overview is available to all GitHub Enterprise customers](https://github.blog/changelog/2022-08-08-security-overview-is-now-available-to-all-github-enterprise-users/)
| +| Apr 06 2022 | Adding [Dependency Review Action (Beta)](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement) | +| Apr 04 2022 | Adding [Secret Scanning - Push Protections (Beta)](https://docs.github.com/en/enterprise-cloud@latest/code-security/secret-scanning/protecting-pushes-with-secret-scanning) | +| Mar 07 2022 | Adding new Security [Overview for the Enterprise (Beta)](https://github.blog/changelog/2022-03-01-security-overview-for-enterprise-in-beta/) and [secret scanning note for public repos](https://github.blog/changelog/2022-03-04-secret-scanning-advanced-security-customers-can-now-view-alerts-on-their-public-repositories/) | +| Jan 26 2022 | Adding [Dependabot section](#about-dependabot), reorganized chart | +| Dec 03 2021 | Initial post | diff --git a/_posts/2021-12-09-github-connecting-to-azure-boards-multiple-orgs.md b/_posts/2021-12-09-github-connecting-to-azure-boards-multiple-orgs.md new file mode 100644 index 0000000..5be4c76 --- /dev/null +++ b/_posts/2021-12-09-github-connecting-to-azure-boards-multiple-orgs.md @@ -0,0 +1,66 @@ +--- +title: 'Connecting Azure Boards GitHub App to Multiple Azure DevOps Orgs/Projects' +author: Josh Johanning +date: 2021-12-09 22:00:00 -0600 +description: Using the Azure Boards GitHub app to connect a single GitHub organization to multiple Azure DevOps organizations or projects for work item integration +categories: [GitHub, Integrations] +tags: [GitHub, Azure Boards, Azure DevOps, Work Items] +media_subpath: /assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs +image: + path: azure-boards-github.png + width: 1458 # in pixels + height: 838 # in pixels + alt: Connecting multiple Azure DevOps organizations or projects to a GitHub org with the Azure Boards GitHub app +--- + +## Overview + +We all probably know by now that there is some pretty solid first-party support for linking GitHub to Azure DevOps, specifically, Azure Boards, with the [Azure Boards GitHub app](https://docs.microsoft.com/en-us/azure/devops/boards/github/?view=azure-devops). Assuming you have the right permissions, the [setup is straight forward](https://docs.microsoft.com/en-us/azure/devops/boards/github/install-github-app?view=azure-devops). When going through and setting up the GitHub app, you'll pick the [Azure DevOps organization and project](https://docs.microsoft.com/en-us/azure/devops/boards/github/media/github-app/choose-azure-boards-project.png?view=azure-devops) that you want to link to. + +And this works great - however, what isn't as clear, what if you have a GitHub organization that you want to link to multiple Azure DevOps organizations or projects? Going through the Azure Boards installation process only allows you to select a *single* Azure DevOps organization and linking to a *single* Azure DevOps project. Unless your team is following the [One Project to Rule Them All](https://colinsalmcorner.com/vsts-one-team-project-and-inverse-conway-maneuver/) strategy, you start to realize that this might not be a very tenable solution. The [documentation](https://docs.microsoft.com/en-us/azure/devops/boards/github/troubleshoot-github-connection?view=azure-devops#connecting-to-multiple-azure-devops-organizations) seems to indicate that connecting our GitHub organization to more than one Azure DevOps organization is not recommended (nor possible). + +## Configuration + +After a little playing around, here are the steps that I followed in order to satisfy our scenario of linking our GitHub organization to multiple Azure DevOps organizations with the Azure Boards GitHub app: + +1. Install the [Azure Boards](https://github.com/marketplace/azure-boards) app to your GitHub org - select the Azure DevOps Org #1 organization that you want to link to as well as what GitHub repo(s) to link to +1. Navigate to the GitHub organization --> Settings --> [Installed GitHub Apps](https://docs.microsoft.com/en-us/azure/devops/boards/github/change-azure-boards-app-github-repository-access?view=azure-devops#change-repository-access) --> Azure Boards and make a change (i.e.: select a new repo) and click 'Save' + - If you had selected 'All repositories' in step #1, you will have to select 'Only select repositories' instead and select the repos by hand to be able to click 'Save' on this page +1. Link it to Azure DevOps Org #2 +1. For each of the Azure DevOps organizations, navigate to the project --> [project settings --> GitHub Connections](https://docs.microsoft.com/en-us/azure/devops/boards/github/add-remove-repositories?view=azure-devops#open-project-settingsgithub-connections) to verify the repo mappings are correct - [add/remove](https://docs.microsoft.com/en-us/azure/devops/boards/github/add-remove-repositories?view=azure-devops#add-or-remove-repositories-or-remove-a-connection) GitHub repositories if necessary + +Now you have a single GitHub organization linked to multiple Azure DevOps organizations! + +## Example + +Here it is in action - I created a commit in each repository in GitHub. They both happen to link to `AB#1` since these are both new Azure DevOps organizations and this was the first work item I created in each. I also wanted to prove that there wouldn't be any conflicts of the links that are created. + +![Azure DevOps Org #1](example-org-1.png ){: .shadow } +_Azure DevOps Org #1 linked to GitHub repo A_ + +![Azure DevOps Org #2](example-org-2.png ){: .shadow } +_Azure DevOps Org #2 linked to GitHub repo B_ + +Additionally, you can use the '[Add Repositories](https://learn.microsoft.com/en-us/azure/devops/boards/github/add-remove-repositories?view=azure-devops#add-or-remove-repositories-or-remove-a-connection)' option (available after highlighting the row and using the vertical ellipses) to add additional repositories to the existing GitHub connection. Note that it's a 1-at-a-time process, so you'll have to repeat this step for each additional repository you want to add. +![Example showing multiple repositories linked](multiple-repos-linked.png ){: .shadow } +_Example showing multiple repositories linked to a GitHub connection in Azure DevOps_ + +## Gotchas + +1. Note that *1 GitHub repo* can only be linked to *1 AzDO organization* at a time. However, the GitHub repo can be linked to multiple projects within the same AzDO org. If you try to link a GitHub repo to more than 1 AzDO org, you will see a `null` error message: + + ![null error message trying to add an already-linked GitHub repository](null-error.png ){: .shadow } + _null error message trying to add a GitHub repository already linked to another Azure DevOps org_ +1. You need to install/authorize both the **Azure Boards GitHub App** as well as the **Azure Boards OAuth App** in GitHub. If you don't see your GitHub org in the Azure DevOps [project settings --> GitHub Connections](https://docs.microsoft.com/en-us/azure/devops/boards/github/add-remove-repositories?view=azure-devops#open-project-settingsgithub-connections) when [adding/removing](https://docs.microsoft.com/en-us/azure/devops/boards/github/add-remove-repositories?view=azure-devops#add-or-remove-repositories-or-remove-a-connection) GitHub repositories, try **launching an incognito window** to force the GitHub authentication flow to be able to **grant** authorization to your GitHub organization. This is likely because your GitHub organization is using the [default OAuth policy settings](https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions#about-oauth-app-access-restrictions) where an org owner has to approve OAuth app access. You will only need to do this the one time for your GitHub organization. If you aren't a GitHub org owner, you can pass along the information to an org owner to approve your [OAuth app request](https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data/approving-oauth-apps-for-your-organization). Note that this is different than authorizing for [SSO/SAML](https://docs.github.com/en/enterprise-cloud@latest/apps/oauth-apps/using-oauth-apps/authorizing-oauth-apps#oauth-apps-and-organizations), but you might need to do that too! + + ![Authorizing the Azure Boards OAuth app](authorize-github.png ){: .shadow } + _Don't forget to authorize (grant) the **Azure Boards OAuth app** with your GitHub org as well as installing the **marketplace GitHub app**_ +2. When adding new GitHub repositories to Azure DevOps, the form uses radio buttons so you can only select *1 repo at a time*. If you want to add multiple repos, you will need to use the add repositories button and select each repo individually +3. If you do too much linking back and forth to GitHub, you may run into a *secondary rate limit* issue. Either wait it out or use a different GitHub account to do the repository linking +4. We once saw that the [GitHub connection](https://docs.microsoft.com/en-us/azure/devops/boards/github/add-remove-repositories?view=azure-devops#open-project-settingsgithub-connections) was disabled, but we think that was after trying to create a new GitHub connection from a new Azure DevOps org directly in Azure DevOps without first going through GitHub - if this happens, you should be able to manually re-enable the GitHub connection + +## Summary + +GitHub works best when using a [single org model](https://gist.github.com/rwnfoo/3e19747f6dc2c5b9cfb0ff9c89d834b4). If you wanted to use the Azure Boards integration to link to multiple Azure DevOps organizations or projects, you might be displeased at first after reading the documentation and going through the initial setup - but hopefully the steps in this article will help you configure the integration properly! + +Update: I just tested this in March 2023 and can verify that this still works as expected. I also added/clarified a few gotchas around the GitHub OAuth app authorization flow. diff --git a/_posts/2022-01-04-migrate-svn-to-git.md b/_posts/2022-01-04-migrate-svn-to-git.md new file mode 100644 index 0000000..6099797 --- /dev/null +++ b/_posts/2022-01-04-migrate-svn-to-git.md @@ -0,0 +1,256 @@ +--- +title: 'Migrate SVN to Git' +author: Josh Johanning +date: 2022-01-04 23:00:00 -0600 +description: Migrate SVN to Git using either the import repository feature in GitHub or git-svn +categories: [GitHub, Migrations] +tags: [GitHub, Azure DevOps, SVN, Git, Migrations] +media_subpath: /assets/screenshots/2022-01-04-migrate-svn-to-git +image: + path: svn-to-git.png + width: 100% + height: 100% + alt: Migrating SVN to Git +--- + +## Overview + +Let's face it: Subversion had its time in the sun, but Git is the more modern source control system. If you want to use GitHub and take advantage of all the collaboration and security features, you're going to want your source code in GitHub. In this post, I describe several options on how to make the jump to Git and GitHub and bring your code (including history!) with you. + +## GitHub Importer + +Probably the easiest (and yet the least likely you'll be able to use) is the [GitHub Repo Importer](https://docs.github.com/en/enterprise-cloud@latest/github/importing-your-projects-to-github/importing-source-code-to-github/about-github-importer) (you can use this for SVN, Mercurial, TFVC, and of course, Git). When you create a new repository in GitHub, there is a little blue link that allows you to [Import a repository](https://docs.github.com/en/enterprise-cloud@latest/github/importing-your-projects-to-github/importing-source-code-to-github/importing-a-repository-with-github-importer). If you forget to click the link to import a repository at the time you are creating and naming your GitHub repo, you can still import after repo creation if you haven't initialized the repository with a Readme or .gitignore. + +The reason why I say least likely to be able to use is that this requires your SVN server to be publicly accessible from GitHub.com. Most Subversion servers I run into our hosted on-premises, which means you're pretty much out of luck. + +If this does work for you, provide the repository url, credentials, and if applicable, which project you are importing, and away you go. + +*Note: According to the documentation, the GitHub Repository Importer is not a feature in GitHub Enterprise Server yet.* + +## git-svn + +This is the tool I have the most experience with. Using `git svn` commands, you can create a Git repo from a repo hosted in Subversion (history included). The larger the repo is and the more history there is, the longer the migration will take. Once the repo has been migrated, it can be pushed to GitHub, Azure DevOps, or any other Git host. + +See the [official documentation](https://git-scm.com/book/en/v2/Git-and-Other-Systems-Migrating-to-Git) for migrating from SVN to Git with the `git svn` commands. + +The high-level process is as follows: + +1. Extract the authors from the SVN repo to create an `authors.txt`{: .filepath} mapping file +2. Modify the mapping file with the author names and email addresses +3. Run `git svn clone` command +4. Clean up tags and branches +5. Create a Git repo in GitHub / Azure Repos +6. Add the Git repo remote to the local repo and push + +### System Pre-Reqs + +* Windows: + * [Git for Windows](https://git-scm.com/download/win) + * [TortoiseSVN](https://tortoisesvn.net/downloads.html) - When installing, check the box to install the '**command line client tools**' (not checked by default). Modify or uninstall/re-install if you did not do this with your initial installation. This allows you to run the `svn` commands from the command line + +* macOS Catalina, Big Sur, Monterey, and greater: + * Run this command to install the `git`, `svn`, and `git svn` commands: +`xcode-select --install` + * `git` should already be installed, so alternatively you can just install `svn` with the corresponding `brew` formulae: `brew install subversion` + - You can also ensure you have the latest version of `git`: `brew install git` or `brew upgrade git` + +### Option 1: Tags as Branches + +These commands clone an SVN repository to Git, perform some cleanup, and push it to your Git host of choice. Branches will appear as `/origin/`. In GitHub/Azure DevOps, you can clean this up by re-creating the branch at the root, e.g., creating a new branch `/` based on `/origin/`. You can confirm the commit hashes are the same and then delete the branch under `/origin`. You can delete `/origin/trunk` without re-creating it because trunk should have been re-created as master. + +Tags will appear as branches, e.g.: `/origin/tags/`. You can clean this up by re-creating the tag branch at the root, e.g. `/tags/` or `/`. Otherwise, you can manually create a tag in the tags page in [GitHub](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository)/[Azure DevOps](https://docs.microsoft.com/en-us/azure/devops/repos/git/git-tags?view=azure-devops&tabs=browser#create-tag) based off of the `/origin/tags/` branch reference. Branches and tags are just pointers in Git anyway, so whether it appears as a tag or a branch, the referenced commit SHA will be the same. + +Note: In GitHub, when you create a release, you must specify a tag. So, creating a [release](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository) in the web interface will create a tag. Otherwise, you can use the [command line](https://stackoverflow.com/a/18223354/4270353) to create tags. + +1. Get a list of the committers in an SVN repo: + + ```bash + svn log -q http://svn.mysvnserver.com/svn/MyRepo | awk -F '|' '/^r/ {sub("^ ", "", $2); sub(" $", "", $2); print $2" = "$2" <"$2">"}' | sort -u > authors-transform.txt + ``` + {: .nolineno} + +1. Modify each line to map the SVN username to the Git username, e.g.: `josh = Josh ` + - Make sure the file is encoded as [UTF-8](https://stackoverflow.com/a/57892875/4270353) + +1. Clone an SVN repo to Git: + + ```bash + git svn clone http://svn.mysvnserver.com/svn/MyRepo --authors-file=authors-transform.txt --trunk=trunk --branches=branches/* --tags=tags MyRepo + ``` + {: .nolineno} + + Note: In case of a non-standard layout, replace `trunk`, `branches`, and `tags` with appropriate names + +1. Git Tags cleanup (creating local tags off of the `remotes/tags/` reference so that we can push them): + + ```bash + git for-each-ref refs/remotes/tags | cut -d / -f 4- | grep -v @ | while read tagname; do git tag "$tagname" "tags/$tagname"; git branch -r -d "tags/$tagname"; done + ``` + {: .nolineno} + +1. Git Branches cleanup (creating local branches off of the `remotes/` reference so that we can push them): + + ```bash + git for-each-ref refs/remotes | cut -d / -f 3- | grep -v @ | while read branchname; do git branch "$branchname" "refs/remotes/$branchname"; git branch -r -d "$branchname"; done + ``` + {: .nolineno} + +1. Add the remote: + + ```bash + git remote add origin https://github.com//.git + ``` + {: .nolineno} + +1. Push the local repo to Git host: + + ```bash + git push -u origin --all + ``` + {: .nolineno} + +This is what you can expect tags to look like in [GitHub](https://github.com/joshjohanning/GitSvn-TagsAsBranches/branches/all?query=origin%2F) after running the migration (as branches): +![Option 2 - Tags as Branches in GitHub](option1-github-tags-as-branches.png){: .shadow } +_How tags appear in GitHub (as branches) - You can even see that Dependabot created a few branches!_ + +And in Azure DevOps: +![Option 2 - Tags as Branches in Azure DevOps](option1-azdo-tags-as-branches.png){: .shadow } +_How tags appear in Azure DevOps (as branches)_ + +### Option 2: Tags as Tags + +When following the [above instructions](#option-1-tags-as-branches), tags will appear as a branch `/origin/tags/`. This is usually fine since branches and tags are just pointers in Git anyway, so whether it appears as a tag or a branch, the referenced commit SHA will be the same. + +If you want to see the tags show under the tags page instead of the branches page in [GitHub](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository)/[Azure DevOps](https://docs.microsoft.com/en-us/azure/devops/repos/git/git-tags?view=azure-devops&tabs=browser#create-tag), you can manually create a new tag based on the branch in `/origin/tags/`, or follow the alternative commands below (particularly step `#4`). + +Note: In GitHub, when you create a release, you must specify a tag. So, creating a [release](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository) in the web interface will create a tag. Otherwise, you can use the [command line](https://stackoverflow.com/a/18223354/4270353) to create tags. + +1. Get a list of the committers in an SVN repo: + + ```bash + svn log -q http://svn.mysvnserver.com/svn/MyRepo | awk -F '|' '/^r/ {sub("^ ", "", $2); sub(" $", "", $2); print $2" = "$2" <"$2">"}' | sort -u > authors-transform.txt + ``` + {: .nolineno} + +1. Modify each line to map the SVN username to the Git username, e.g.: `josh = Josh ` + - Make sure the file is encoded as [UTF-8](https://stackoverflow.com/a/57892875/4270353) + +1. Clone an SVN repo to Git: + + ```bash + git svn clone http://svn.mysvnserver.com/svn/MyRepo --authors-file=authors-transform.txt --trunk=trunk --branches=branches/* --tags=tags MyRepo + ``` + {: .nolineno} + + Note: In case of a non-standard layout, replace `trunk`, `branches`, and `tags` with appropriate names + +1. Create Git Tags based on the message that was originally in SVN. + + ```bash + git for-each-ref --format="%(refname:short) %(objectname)" refs/remotes/origin/tags \ + | while read BRANCH REF + do + TAG_NAME=${BRANCH#*/} + BODY="$(git log -1 --format=format:%B $REF)" + echo "ref=$REF parent=$(git rev-parse $REF^) tagname=$TAG_NAME body=$BODY" >&2 + git tag -a -m "$BODY" $TAG_NAME $REF^ &&\ + git branch -r -d $BRANCH + done + ``` + +1. Git Branches cleanup (creating local branches off of the `remotes/` reference so that we can push them): + + ```bash + git for-each-ref refs/remotes | cut -d / -f 3- | grep -v @ | while read branchname; do git branch "$branchname" "refs/remotes/$branchname"; git branch -r -d "$branchname"; done + ``` + {: .nolineno} + +1. Add the remote: + + ```bash + git remote add origin https://github.com//.git + ``` + {: .nolineno} + +1. Push the local repo to Git host: + + ```bash + git push -u origin –all + ``` + {: .nolineno} + +1. Push the tags to Git host: + + ```bash + git push --tags + ``` + {: .nolineno} + +This is what you can expect tags to look like in [GitHub](https://github.com/joshjohanning/GitSvn-Tags/tags) after running the migration (as tags): +![Option 2 - Tags as Tags in GitHub](option2-github-tags-as-tags.png){: .shadow } +_How tags appear in GitHub (as tags)_ + +And in Azure DevOps: +![Option 2 - Tags as Tags in Azure DevOps](option2-azdo-tags-as-tags.png){: .shadow } +_How tags appear in Azure DevOps (as tags)_ + +### Clone partial history from SVN + +This can be useful if you only want/need history from the last X months or last N revisions cloned from the SVN repository. This can help to speed up the conversion as well as potentially bypassing any errors (such as server timeout). You must pick/find what revision you want to start with manually, though. In this example I am getting everything from revision 3000 to current (HEAD): + +```bash +git svn clone -r3000:HEAD http://svn.mysvnserver.com/svn/MyRepo --authors-file=authors-transform.txt --trunk=trunk --branches=branches/* --tags=tags MyRepo +``` + +You can use an SVN client ([TortoiseSVN](https://tortoisesvn.net/downloads.html) on Windows, [SmartSVN](https://www.smartsvn.com/download/) on Mac) or [git svn log](https://svnbook.red-bean.com/en/1.7/svn.ref.svn.c.log.html) to help you with finding out what revision to start with. Alternatively, if you want to precisely find the previous N revision, you can use the 3rd party scripts found [here](https://github.com/jonathancone/svn-utils). + +### Metadata + +The `--no-metadata` option can be used in the `git svn` command (steps `#3` above) for one-shot imports, like we are essentially what we are doing here, but it won't include the git-svn-id (url) in the new git commit message. If this is a one-shot import, and you don't want to be cluttered with the old git-svn-id (url), include this option. + +From the [git-svn documentation](https://git-scm.com/docs/git-svn#Documentation/git-svn.txt-svn-remoteltnamegtnoMetadata): + +> Set the `noMetadata` option in the [svn-remote] config. This option is not recommended. +> +> This gets rid of the `git-svn-id`: lines at the end of every commit. +> +> This option can only be used for one-shot imports as `git svn` will not be able to fetch again without metadata. Additionally, if you lose your `$GIT_DIR/svn/**/.rev_map.*` files, `git svn` will not be able to rebuild them. + +You can compare the difference between adding `--no-metadata` and not in the examples of my migration runs: +- [Tags as Branches](https://github.com/joshjohanning/GitSvn-TagsAsBranches) (with `--no-metadata`) +- [Tags as Tags](https://github.com/joshjohanning/GitSvn-Tags) (without `--no-metadata`) + +Note that my initial commit in SVN didn't have a commit message, that's why it's showing "No commit message" for most of the files. `git svn` migrates commit messages with or without `--no-metadata`. + +### Resources / Bookmarks + +This is my stash of references I used that may be helpful for you: + +* [Converting a Subversion repository to Git](https://john.albin.net/git/convert-subversion-to-git) and [cleaning up binaries in the process](https://meejah.ca/blog/migrating-svn-to-git#remove-all-the-things) +* [tortoise svn giving me "Redirect cycle detected for URL 'domain/svn'"](https://stackoverflow.com/questions/10524646/tortoise-svn-giving-me-redirect-cycle-detected-for-url-domain-svn) +* [Why do I get “svn: E120106: ra_serf: The server sent a truncated HTTP response body” error?](https://stackoverflow.com/questions/27267742/why-do-i-get-svn-e120106-ra-serf-the-server-sent-a-truncated-http-response-b) +* [How to import svn branches and tags into git-svn?](https://stackoverflow.com/a/27210896/4270353) and [convert git-svn tag branches to real tags](https://gitready.com/advanced/2009/02/16/convert-git-svn-tag-branches-to-real-tags.html) +* [What is the format of an authors file for git svn, specifically for special characters like backslash or underscore?](https://stackoverflow.com/questions/2159567/what-is-the-format-of-an-authors-file-for-git-svn-specifically-for-special-char) +* [Git svn clone with author name like "/CN=myname"](https://stackoverflow.com/questions/16687094/git-svn-clone-with-author-name-like-cn-myname/25012409#25012409) +* [Author not defined when importing SVN repository into Git](https://stackoverflow.com/questions/11037166/author-not-defined-when-importing-svn-repository-into-git/28303400) (make sure the file is encoded as [UTF-8](https://stackoverflow.com/a/57892875/4270353)) +* [git svn --ignore-paths regex](https://github.com/git/git-scm.com/issues/698#issuecomment-239408209), and [How is ignore-paths evaluated in git svn?](https://stackoverflow.com/questions/10448431/how-is-ignore-paths-evaluated-in-git-svn) +* [SVN and KeepAlive (svn: E175002: Connection reset)](https://marioharvey.com/svn-and-keepalive/) +* [How to git-svn clone the last n revisions from a Subversion repository?](https://stackoverflow.com/a/35661167/4270353) and [Git Svn clone certain revision, and continue cloning other revisions in the future](https://stackoverflow.com/questions/17689582/git-svn-clone-certain-revision-and-continue-cloning-other-revisions-in-the-futu/17768204) + +## svn2git + +GitHub's [importing source code to GitHub documentation](https://docs.github.com/en/enterprise-cloud@latest/github/importing-your-projects-to-github/importing-source-code-to-github/source-code-migration-tools#importing-from-subversion) mentions another tool you can use as well - [svn2git](https://github.com/nirvdrum/svn2git). I do not have any experience with this tool but wanted to call it out here as another option. + +## Tip Migration + +I'd be remiss if I did not mention that there's always the option of *just migrating the tip* - meaning, grab the latest code from SVN and *start fresh with a new repo in GitHub*. Leave all of the history in SVN and start fresh in GitHub by coping in the files, creating a gitignore to exclude any binaries and other unwanted files, and pushing. Ideally, you could keep the SVN server around for a while or make an archive somewhere that it would still be possible to view / recover the history. + +Understandably, this won't work for everyone, but it is always an option if the migration options aren't worth the effort, and you really just care about your most recent code being in GitHub. + +## Wrap-up + +Now that you have your code migrated to Git, the hard part of moving to GitHub is behind you. Even if you're not using GitHub, migrating from SVN to Git certainly has its advantages. + +I will note that once the code is in GitHub, it is technically possible to use [svn clients](https://docs.github.com/en/github/importing-your-projects-to-github/working-with-subversion-on-github/support-for-subversion-clients) to connect to repositories on GitHub, if you're in GitHub I think it is wise to use Git like everyone else in GitHub :). + +Did I miss anything, or have you any improvements to be made? Let me know in the comments! diff --git a/_posts/2022-01-07-github-download-from-github-packages.md b/_posts/2022-01-07-github-download-from-github-packages.md new file mode 100644 index 0000000..66216fc --- /dev/null +++ b/_posts/2022-01-07-github-download-from-github-packages.md @@ -0,0 +1,191 @@ +--- +title: 'Programmatically Download a Package Binary from GitHub Packages' +author: Josh Johanning +date: 2022-01-07 14:30:00 -0600 +description: Programmatically download a package binary (such as NuGet, Maven) from GitHub Packages +categories: [GitHub, Packages] +tags: [GitHub, GitHub Packages, Maven, NuGet, npm] +media_subpath: /assets/screenshots/2022-01-07-github-download-from-github-packages +image: + path: github-packages.png + width: 100% + height: 100% + alt: Assets for a package in GitHub Packages +--- + +## Overview + +We had a team that wanted to push to GitHub packages, which is relatively easily enough to do and is well [documented](https://docs.github.com/en/packages/working-with-a-github-packages-registry). However, they had a subsequent job that was building a Docker image where that dependency (the `.jar`{: .filepath} or `.war`{: .filepath} file) was needed. + +There are a couple of different ways you could think about this. + +1. Maybe the `docker build` step should occur in the same job as the `mvn build` step so that it has access to the same binary outputs +2. Perhaps instead of GitHub Packages we create a Release on the repository - we can use an [Action](https://github.com/softprops/action-gh-release) to do this and an [API](https://docs.github.com/en/rest/reference/releases#get-a-release-asset) to download the release +3. If we really just want to download the package binary from GitHub Packages...that should be simple enough, right? + +## Just use the Packages API, right? + +The [API for GitHUb Packages](https://docs.github.com/en/rest/reference/packages) says: + +> With the GitHub Packages API, you can **manage** packages for your GitHub repositories and organizations. + +Keyword: **manage**, such as listing or deleting packages. It doesn't really imply *downloading* or *retrieiving package assets* like the [Release API](https://docs.github.com/en/rest/reference/releases#get-a-release-asset) has. + +Okay, but can't we just go to the package in the UI and copy the download link? + +Nope -- check the URL of one of the files in my repo: + +> https://github-registry-files.githubusercontent.com/445574648/92585100-6fe8-11ec-8a00-38630c14852f?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20220107%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20220107T193611Z&X-Amz-Expires=300&X-Amz-Signature=96f4809aebb229ea01b80832c12e546810837194203927c39a31f2c875b177fd&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=445574648&response-content-disposition=**filename%3Dherokupoc-1.0.0-202201071835.jar**&response-content-type=application%2Foctet-stream + +Pretty nasty huh? It looks to be a timed download URL. + +After spending a few hours on this, there were a few ways I found to do this with various levels of monstrocities committed in finding. I'll start with the best / easiest and work my way down. + +## Mysteriously hidden URLs to CURL + +In hindsight, it's so simple (at least for Maven), yet it's not documented anywhere! I was trying to use the `mvn dependency:get/copy` cli and kept getting stuck on a `401 unauthorized` error message. In the logs, I saw the URL to the `.jar`{: .filepath} file I was trying to download and decided to paste that into my browser. I received a username/password basic auth prompt, and I simply pasted in my PAT as a password and I was able to download that file. + +Extrapulating to `curl`, this was how to replicate this in the command line: + +{% raw %} +```bash +curl 'https://maven.pkg.github.com///com////.jar' \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -L -O +``` + +And because my biggest pet peave is when someone has this awesome blog post but then hides/obfuscates all the good stuff, here's my actual CURL command I used to download a file: + +```bash +curl 'https://maven.pkg.github.com/joshjohanning-org/sherlock-heroku-poc-mvn-package/com/sherlock/herokupoc/1.0.0-202201071559/herokupoc-1.0.0-202201071559.jar' \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -L -O +``` + +The `-L` is important here as this tells `curl` to follow redirects. Without it, you'll get a '301 Moved Permanently' because it's trying to use use the expanded URL as mentioned above. If you added the `-v` option to the command, you would see a similar long URL that our `curl` follows the redirect to. + +The `-O` downloads the file locally with the same name as in the URL. + +### Maven URL + +As noted, the Maven URL + +Format: + +``` +https://maven.pkg.github.com///com////.jar +``` + +Example: + +``` +curl -H "Authorization: token ghp_xyz" -L -O \ + https://maven.pkg.github.com/joshjohanning-org/download/com/sherlock/herokupoc/1.0.0-202202122241/herokupoc-1.0.0-202202122241.jar +``` + +### NuGet URL + +Format: + +``` +https://nuget.pkg.github.com//download///..nupkg +``` + +Example: + +``` +curl -H "Authorization: token ghp_xyz" -L -O \ + https://nuget.pkg.github.com/joshjohanning-org/download/Wolfringo.Hosting/1.1.1/Wolfringo.Hosting.1.1.1.nupkg +``` + +### npm URL + +`npm` must work a bit differently; the download URL is different: + +```bash +# get package versions +version="0.0.3" +token="ghp_xyz" +org="joshjohanning-org" +package_name="npm-package-example" + +# get url +url=$(curl -H "Authorization: token $token" -Ls https://npm.pkg.github.com/@$org/$package_name | jq --arg version $version -r '.versions[$version].dist.tarball') +# download +curl -H "Authorization: token $token" -L -o $package_name-$version.tgz $url +``` + +## Other options + +In my quest to find out how to download a Maven package from GitHub packages, I stumbled upon a few other options that I'll list here for posterity. + +### mvn install and mv + +I was able to get something like this to work - see my GitHub Action job below: + +```yml + download-job: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: maven-settings-xml-action + uses: whelk-io/maven-settings-xml-action@v20 + with: + repositories: '[{ "id": "fix_world", "url": "https://maven.pkg.github.com/joshjohanning-org/sherlock-heroku-poc-mvn-package" }]' + servers: '[{ "id": "fix_world", "username": "joshjohanning", "password": "${{ secrets.GITHUB_TOKEN }}" }]' + + - name: View settings.xml + run: cat ~/.m2/settings.xml + + - name: Install with Maven + run: mvn install -s ~/.m2/settings.xml + + # wildcard find and mv command to current directory + - name: mv + run: find /home/runner/.m2 -name "*herokupoc-1.*.jar" -exec mv {} . \; +``` + +### mvn cli - sort of + +This is what we were originally trying to do, so thought I would throw it in here to spur other ideas for other languages. We were trying to use `mvn dependenct:get` to download the `.jar`{: .filepath} file, but were ultimately uncessful for one reason or another. + +This is the job we ended with: + +```yml + mvncli: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: maven-settings-xml-action + uses: whelk-io/maven-settings-xml-action@v20 + with: + repositories: '[{ "id": "fix_world", "url": "https://maven.pkg.github.com/joshjohanning-org/sherlock-heroku-poc-mvn-package" }]' + servers: '[{ "id": "fix_world", "username": "joshjohanning", "password": "${{ secrets.GITHUB_TOKEN }}" }]' + - name: download + run: | + mvn dependency:get \ + -DgroupId=com.sherlock \ + -DartifactId=herokupoc \ + -Dversion=1.0.0-202201071835 \ + -Dpackaging=jar \ + -Dclassifier=sources \ + -DremoteRepositories=central::default::https://repo.maven.apache.org/maven2,fix_world::::https://maven.pkg.github.com/joshjohanning-org/sherlock-heroku-poc-mvn-package +``` + +But gives us an authentication error: + +> Error: Failed to execute goal org.apache.maven.plugins:maven-dependency-plugin:3.1.1:get (default-cli) on project herokupoc: Couldn't download artifact: org.eclipse.aether.resolution.DependencyResolutionException: Could not transfer artifact com.sherlock:herokupoc:jar:sources:1.0.0-202201071835 from/to fix_world (https://maven.pkg.github.com/joshjohanning-org/sherlock-heroku-poc-mvn-package): authentication failed for **https://maven.pkg.github.com/joshjohanning-org/sherlock-heroku-poc-mvn-package/com/sherlock/herokupoc/1.0.0-202201071835/herokupoc-1.0.0-202201071835-sources.jar**, status: 401 Unauthorized -> [Help 1] + +But this was still useful though as this what led me to just try to [`curl` that `.jar`{: .filepath} file URL](#mysteriously-hidden-urls-to-curl) successfully 😀. + +### GraphQL + +This [post used to include](https://github.com/joshjohanning/joshjohanning.github.io/blob/168740292d487f3dc841b54f630f995420f4b924/_posts/2022-01-07-github-download-from-github-packages.md?plain=1#L71-L244) a GraphQL reference for downloading a file from GitHub Packages, but the GraphQL endpoint for GitHub Packages has been [deprecated on GitHub.com](https://github.blog/changelog/2022-08-18-deprecation-notice-graphql-for-packages/) and [GitHub Enterprise Server 3.7.0+](https://docs.github.com/en/enterprise-server@3.7/admin/release-notes#3.7.0-deprecations) and no longer works. + +## Wrap-up + +Hopefully this either helped you download a file from GitHub Packages, gives you ideas for other languages, or maybe my struggles convince you to *just use a [Release in GitHub](https://docs.github.com/en/repositories/releasing-projects-on-github)*. + +I should mention that if you're running this in a GitHub Action workflow from a repository other than the repository publishing the package, you may have to [update the package's permissions](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility#ensuring-workflow-access-to-your-package) for the repository to have access to the package in (not available in Maven [yet](https://github.com/github/roadmap/issues/578)). + +Let me know what I've missed or if you have any other ideas! 📦 + +{% endraw %} diff --git a/_posts/2022-01-21-github-misc-scripts.md b/_posts/2022-01-21-github-misc-scripts.md new file mode 100644 index 0000000..3345122 --- /dev/null +++ b/_posts/2022-01-21-github-misc-scripts.md @@ -0,0 +1,48 @@ +--- +title: 'Miscellaneous GitHub API/GraphQL/CLI Automation Scripts' +author: Josh Johanning +date: 2022-01-20 13:00:00 -0600 +description: Miscellaneous GitHub scripts written against GitHub API's, GraphQL, GitHub CLI, etc. for automation +categories: [GitHub, Scripts] +tags: [GitHub, Scripts, gh cli] +--- + +## Overview + +I have a large Postman workspace for all my API calls, but it’s sometimes hard to share an example of an API or script with someone. Thus, I decided to create a repo that consolidates my random GitHub scripts into one central spot. Now, I can simply send a link! + +Here's the repo: [joshjohanning/github-misc-scripts](https://github.com/joshjohanning/github-misc-scripts) + +## Layout + +I have them categorized by type: + +* [api](https://github.com/joshjohanning/github-misc-scripts/tree/main/api) +* [gh-cli](https://github.com/joshjohanning/github-misc-scripts/tree/main/gh-cli) +* [graphql](https://github.com/joshjohanning/github-misc-scripts/tree/main/graphql) +* [scripts](https://github.com/joshjohanning/github-misc-scripts/tree/main/scripts) + +I have readme's in each of the folders with a brief description of the enclosed scripts. + +## Script Examples + +Here's an example of some of the scripts I have populated in there so far: + +- [download file from github packages](https://github.com/joshjohanning/github-misc-scripts/blob/main/api/download-file-from-github-packages.sh) (api) - (and my [blog post](https://josh-ops.com/posts/github-download-from-github-packages/)!) +- [create repo](https://github.com/joshjohanning/github-misc-scripts/blob/main/api/create-repo.sh) (api) +- [download file from private repo](https://github.com/joshjohanning/github-misc-scripts/blob/main/api/download-file-from-private-repo.sh) (api) +- [download workflow artifacts](https://github.com/joshjohanning/github-misc-scripts/blob/main/api/download-workflow-artifacts.sh) (api) +- [get enterprise id](https://github.com/joshjohanning/github-misc-scripts/blob/main/graphql/get-enterprise-id.sh) (graphql) +- [create organization](https://github.com/joshjohanning/github-misc-scripts/blob/main/graphql/create-organization.sh) (graphql) +- [delete repository branch policy](https://github.com/joshjohanning/github-misc-scripts/blob/main/graphql/delete-repository-branch-policy.sh) (graphql) +- [get issue id](https://github.com/joshjohanning/github-misc-scripts/blob/main/graphql/get-issue-id.sh) (graphql) +- [get repository branch policies](https://github.com/joshjohanning/github-misc-scripts/blob/main/graphql/get-repository-branch-policies.sh) (graphql) +- [get repository id](https://github.com/joshjohanning/github-misc-scripts/blob/main/graphql/get-repository-id.sh) (graphql) +- [transfer issue](https://github.com/joshjohanning/github-misc-scripts/blob/main/graphql/transfer-issue.sh) (graphql) +- [download specific version from github packages](https://github.com/joshjohanning/github-misc-scripts/blob/main/graphql/download-specific-version-from-github-packages.sh) (graphql) - (and my [blog post](https://josh-ops.com/posts/github-download-from-github-packages/)!) +- [download latest version from github packages](https://github.com/joshjohanning/github-misc-scripts/blob/main/graphql/download-latest-version-from-github-packages.sh) (graphql) - (and my [blog post](https://josh-ops.com/posts/github-download-from-github-packages/)!) +- [get sso credential authorizations (PATs, SSH Keys)](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-sso-credential-authorizations.sh) (gh-cli) + +## Overview + +Let me know if you have found any of these useful and/or have improved them! diff --git a/_posts/2022-02-07-github-apps.md b/_posts/2022-02-07-github-apps.md new file mode 100644 index 0000000..e9f77cc --- /dev/null +++ b/_posts/2022-02-07-github-apps.md @@ -0,0 +1,236 @@ +--- +title: 'Demystifying GitHub Apps: Using GitHub Apps to Replace Service Accounts' +author: Josh Johanning +date: 2022-02-07 20:00:00 -0600 +description: Creating no-code GitHub Apps to install to an organization to replace having to create service accounts or a user PAT for authorization in GitHub Actions +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, GitHub Apps, GitHub Issues] +media_subpath: /assets/screenshots/2022-02-07-github-apps +image: + path: github-apps.png + width: 100% + height: 100% + alt: An example GitHub App +--- + +## Overview + +In GitHub Actions, the [GitHub Token](https://dev.to/github/the-githubtoken-in-github-actions-how-it-works-change-permissions-customizations-3cgp) works very well and is convenient for automation tasks that require authentication, but its scope is limited. The GitHub Token is only going to allow us to access data within the repository (such as issues, code, packages), but what if you need to authenticate to another repository, or access organizational information such as teams or member lists? GitHub Token is not going to work for that. Your alternatives are: + +1. Use someone's Personal Access Token (PAT) - but what happens if that person leaves? Or if you need to write back to an issue, for example, it's going to look like it came from that user +2. Create a service account - but this is going to consume a license, and you still have to manage with vaulting and storing a long-lived PAT somewhere, and if that PAT gets exposed, you're opening yourselves up to a huge security risk +3. Creating a **GitHub App** and using it for authentication! 🚀 + +In this post, I will go through the setup and usage of GitHub Apps in an Actions workflow with two scenarios: [Using a GitHub App to grant access to a single repository](#scenario-1-using-a-github-app-to-grant-access-to-a-single-repository) and [Using a GitHub App as a rich comment bot](#scenario-2-using-a-github-app-as-a-rich-comment-bot). + +And don't worry - you don't need any programming experience to create a GitHub App! + +## GitHub Apps + +GitHub Apps are certainly preferred and recommended from GitHub. From [GitHub's documentation](https://docs.github.com/en/developers/apps/getting-started-with-apps/about-apps), this fits our exact use case: + +> GitHub Apps are the official recommended way to integrate with GitHub because they offer much more granular permissions to access data. +> +> GitHub Apps are first-class actors within GitHub. A GitHub App acts on its own behalf, taking actions via the API directly using its own identity, which means you don't need to maintain a bot or service account as a separate user. + +GitHub Apps also have a [higher API rate limiting threshold](https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting) than requests from user accounts. [Installed in an Enterprise](https://github.blog/changelog/2020-07-27-increase-to-api-limits-in-github-enterprise-subscriptions/), GitHub Apps can have up to [15,000 requests per hour](https://docs.github.com/en/apps/creating-github-apps/setting-up-a-github-app/rate-limits-for-github-apps#installation-access-tokens-on-github-enterprise-cloud) whereas user-created personal access tokens have a limit of 5,000 requests per hour. For non-Enterprise organizations, there is a [formula](https://docs.github.com/en/apps/creating-github-apps/setting-up-a-github-app/rate-limits-for-github-apps#installation-access-tokens-on-githubcom) that is used to calculate the rate limit based on the number of users in the organization, but it's still higher than the 5,000 requests per hour that a user-created personal access token has. + +When authenticating with the `GITHUB_TOKEN` in a GitHub Actions workflows in an Enterprise organization, you also have access to the [15,000 requests per repository per hour](https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#rate-limits-for-requests-from-github-actions). Outside of an Enterprise organization, you're limited to 1,000 requests per hour. However, the `GITHUB_TOKEN` in the Actions workflow expires when the workflow is complete and can only access resources inside of the repository calling the workflow. It cannot access resources outside of the repository, such as other repositories or organizational resources. + +### Caveats + +- Each organization can only own up to [100 GitHub Apps](https://docs.github.com/en/developers/apps/getting-started-with-apps/about-apps#about-github-apps) +- You'll have to be an [organization owner](https://docs.github.com/en/organizations/managing-peoples-access-to-your-organization-with-roles/roles-in-an-organization#organization-owners) to create an app that's owned by the organization, and only organization owners can install a GitHub App + - Non-organization owners can be designated as [GitHub App managers](https://docs.github.com/en/apps/maintaining-github-apps/about-github-app-managers) to be able to create and update GitHub Apps owned by the organization (this is a better way than being owned by a user account) + - Non-organization owners can also create a GitHub App owned by their user account and request it to be installed by the org, but it will be owned by the user and not the organization (not recommended) +- Each installation access token is only valid for a [maximum of one hour](https://docs.github.com/en/rest/reference/apps#create-an-installation-access-token-for-an-app) + - But the fact that it does expire is a good thing! +- GitHub Apps [can't be used to authenticate to GitHub Packages](https://github.com/orgs/community/discussions/26920) (have to use a personal access token) +- If you make a [GitHub App private](https://docs.github.com/en/enterprise-cloud@latest/developers/apps/managing-github-apps/making-a-github-app-public-or-private), other apps can't see it / interact with it + - Example: You can't use GitHub App A to modify a branch protection policy to let GitHub App B to bypass the policy if GitHub App B is private - GitHub App B would have to be a Public app + +## Creating a GitHub App + +Creating a GitHub App is pretty straightforward! I'll defer to [GitHub's documentation for the details](https://docs.github.com/en/developers/apps/building-github-apps/creating-a-github-app), but here's a quick overview: + + +> [Organization owner](https://docs.github.com/en/organizations/managing-peoples-access-to-your-organization-with-roles/roles-in-an-organization#organization-owners) or [GitHub App manager](https://docs.github.com/en/organizations/managing-peoples-access-to-your-organization-with-roles/roles-in-an-organization#github-app-managers) permissions are required in order to create a GitHub App on behalf of an organization. You *can* create a GitHub App in your user account and request it to be installed to the organization, but long term it's better to have the GitHub App owned by the organization. +{: .prompt-tip } + +1. Navigate to the **organization's settings** page +2. Expand the "**Developer Settings**" section at the bottom of the page and navigate to **GitHub Apps** +3. Click "**New GitHub App**" in the upper right-hand corner +4. Start filling in the details! + - The name and Homepage URL doesn't matter much right now (but it does need a valid URL here) + - *(Optional - for use with webhooks)* - If you want the GitHub App to send webhooks based on events, and want an easy way to inspect the payload that is being sent, you can use something like [smee.io](https://smee.io/) to create a channel and use the [URL of the channel](https://smee.io/cm3xXguj5Ds9hs8) as the Webhook URL +5. Grant it the **repository permissions**, **organization permissions**, **user permissions**, and what events to subscribe to - for the examples in this blog post, we'll grant **read-only** access to `repository / contents`, **read & write** access to `repository / issues`, and **read-only** access to `organization / members` - if you change this after the it's already been installed to an organization, you'll have review and re-approve the permission changes for the GitHub App +6. After creation, you should see the **App ID** - we will need this later on + - *(Optional - for use with webhooks)* - After creation, you should see a "ping" entry in your [smee.io channel](https://smee.io/cm3xXguj5Ds9hs8) - this is a confirmation that the app was created - the **App ID** is also available in this payload +7. Scroll down and **[generate a private key for the app](https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#generating-a-private-key)** - download the file and grab the contents of the certificate by opening it in VSCode or if you are on macOS, use the command to copy to your clipboard: `cat approveops-bot.2022-02-07.private-key | pbcopy` +8. Scroll back up and in the left-hand menu, click on the "**Install App**" link and install the app to the organization + - Determine if you will grant the app access to all repos in an organization, or only to selected repo(s) +9. After installing the app, pay attention to the URL in the browser (see screenshot below) - the number at the end of the URL is the **installation ID**, and depending on how you're using the app, you may need this + - *(Optional - for use with webhooks)* - In your [smee.io channel](https://smee.io/cm3xXguj5Ds9hs8), you should have a new payload from the installation - expand the "installation" property to find your "**installation ID**" + +🎉 With the app installed and knowing the **App ID**, **Installation ID**, and **Private Key**, we now have everything we need to generate an installation token use the app in Actions! 🥳 + +![Installation ID after installing GitHub App](installation-id-github.png ){: .shadow } +_An example of an Installation ID in the address bar after installing a GitHub App_ + +![Installation and App ID from a payload in smee.io](installation-id.png ){: width="600" }{: .shadow } +_An example of an Installation ID and App ID from a payload in smee.io_ + +## Using the GitHub App in a GitHub Actions workflow + +Now that GitHub has a first-party action, I tend to always prefer to use that one: + +- [actions/create-github-app-token](https://github.com/marketplace/actions/create-github-app-token) + +It's really quite simple to use an app in Actions now that you have the app ID, the org name, and the private key. The only prerequisite is to create a secret on the repository (or [organization](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-an-organization)) with the private key's contents, taking care not to modify the key's value in any way. I named my secret `PRIVATE_KEY`. + +There are other popular actions in the marketplace that I have used in the past, for reference: + +- [tibdex/github-app-token](https://github.com/marketplace/actions/github-app-token) +- [peter-murray/workflow-application-token-action](https://github.com/marketplace/actions/workflow-application-token-action) + +Different actions ask for different things: sometimes the app ID and installation ID and sometimes the app ID and org name. The installation ID is tied to the org (each org an app is installed to has a unique installation ID). If you need it for another action, the installation ID is available in the URL when you install the app. We are always going to need use the private key though. + +### Scenario 1: Using a GitHub App to grant access to a single repository + +A customer had a repository that nearly every Actions workflow was going to need to access at deploy-time. For the proof of concept, one of the admins on the team created a PAT and added it as an organizational secret. The problem is though that if the PAT is compromised, that PAT has access to _all the repositories in the organization_. + +If there's a centralized repository that every team needs to access, you can use the GitHub App to grant access to that repository and that repository alone. Note that you could also use [deploy keys](https://docs.github.com/en/developers/overview/managing-deploy-keys) for this, but that requires you to use the SSH protocol when cloning. We'll continue as if the GitHub App is the preferred way to go so that you can understand the process. + +Here's the action code to generate and sign an installation access token for authenticating with GitHub: + +{% raw %} +```yml + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + # optional: owner not needed IF the app has access to the repo running the workflow + # if you get 'RequestError [HttpError]: Not Found 404', pass in owner + owner: ${{ github.repository_owner }} + + # example 1a - cloning repo - clone using the `actions/checkout` step + - name: Checkout + uses: actions/checkout@v4 + with: + repository: my-org/my-repo-2 + token: ${{ steps.app-token.outputs.token }} + path: my-repo + + # example 1b - cloning repo - using git clone command + - name: Clone Repository + run: | + mkdir my-repo-2 && cd my-repo-2 + git clone https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/my-org/my-repo.git + + # example 2a - api - call an api using curl + - name: Get Repo (curl) + run: | + curl \ + -H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \ + https://api.github.com/repos/joshjohanning-org/composite-caller-1 + + # example 2b - api - call an api using the GitHub CLI + - name: Get Repo (gh api) + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh api /repos/joshjohanning-org/composite-caller-1 +``` +{: file='.github/workflows/test-permissions.yml'} +{% endraw %} + +With the GitHub App only having access to the `my-org/my-repo-2` repository and passing in the `owner` to the action, we can clone the other repository even though it's not the repository running the workflow. We can also use the token generated here as a Bearer token for GitHub API requests or set the `GH_TOKEN` environment variable for `gh` CLI commands, assuming the app has the proper access to call the API endpoint(s). + +![Using a GitHub App's credentials to clone a repo and query the API](clone-from-app.png){: .shadow } +_Using a GitHub App's credentials to clone a repo and query the API_ + +🎉 We can clone the repo as well as query the API without using a PAT! 🥳 + +### Scenario 2: Using a GitHub App as a rich comment bot + +I'll often use the [peter-evans/create-or-update-comment](https://github.com/marketplace/actions/create-or-update-comment) action to create a comment on a pull request or issue. Typically, I'll just use the {% raw %}`${{ secrets.GITHUB_TOKEN }}`{% endraw %} for the `token` which comments as the `github-actions` bot, and it works great! + +{% raw %} +![GitHub Actions Comment Bot](github-actions-bot.png ){: .shadow } +_GitHub Actions Comment Bot using GitHub Token from the Action run_ +{% endraw %} + +However, if you look closely, you might notice something: since the GitHub Token only has access to the repository, it can't create the proper `@team` mention in the comment. There is no hyperlink there. This might not be super important, but in my case the team was going to use their GitHub notifications to check if there were any issues that needed their attention, so this wasn't going to work. + +If we use a PAT and a secret on the repository, we get the `@team` mention, but it looks like it came from the user who created the PAT: + +![GitHub Actions Comment Bot](pat-bot.png ){: .shadow } +_Issues comment from GitHub Actions using a PAT_ + +Instead, we can use a GitHub App that with **read-only** permissions on `Organization / Members` and **read & write** on `Repository / Issues` to create the comment and then we'll have the mention as well as not coming from a regular user: + +![GitHub Actions Comment Bot](app-bot.png ){: .shadow } +_Issues comment from GitHub Actions using a GitHub App_ + +Here's the relevant action code: + +{% raw %} +```yml + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + + - if: ${{ steps.check-approval.outputs.approved == 'false' }} + name: Create completed comment + uses: peter-evans/create-or-update-comment@v1 + with: + token: ${{ steps.app-token.outputs.token }} + issue-number: ${{ github.event.issue.number }} + body: | + Hey, @${{ github.event.comment.user.login }}! + :cry: No one approved your run yet! Have someone from the @joshjohanning-org/approver-team run `/approve` and then try your command again + :no_entry_sign: :no_entry: Marking the workflow run as failed +``` +{: file='.github/workflows/approveops.yml'} +{% endraw %} + +You'll notice that we didn't have to pass in an `owner` input this time to the action. This is because the GitHub App is installed on the repository running the action, and therefore the action can find the app's installation dynamically to create the token. + +🎉 Issue comment with team mentioning success! 🥳 + +> Check out my next post, [ApproveOps: Approvals in IssueOps](/posts/github-approveops), for more information on the approval action workflow I'm using above. +{: .prompt-info } + +## Generating the GitHub App Installation Token Locally + +If you wanted to be able to generate a token locally, whether for scripting not being ran in Actions or simply to test out an app's permissions, you can use the [Link-/gh-token](https://github.com/Link-/gh-token?tab=readme-ov-file) `gh` CLI extension. + +![Generating a GitHub App Installation Token with a gh CLI extension](gh-token.png ) +_Generating a GitHub App Installation Token with a [`gh` CLI extension](https://github.com/Link-/gh-token?tab=readme-ov-file)_ + +You can tweak this such that it returns an installation token for a particular installation ID with: + +```sh +gh token generate \ + --app-id 1122334 \ + --installation-id 12345678 \ + --key /path/to/private-key.pem \ + --token-only +``` +{: .nolineno} + +## Summary + +When I first learned about GitHub Apps, I was like, "This is cool, but I'm not going to be writing an app and creating code just for authentication, that's too much work, I'll just use a PAT." However, as you just saw, we created a GitHub App and used it for authentication without tying it to any code. + +{% raw %} +In both scenarios, we use the [actions/create-github-app-token](https://github.com/marketplace/actions/create-github-app-token) action and the installation access token that is an output parameter: `${{ steps.app-token.outputs.token }}`. We use this token to make authenticated requests to the API or as the password in Git clones. Alternatively, [GitHub has sample Ruby code](https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app) for creating a and signing the JWT and retrieving an installation ID, but the action is so much simpler! + +Hurrah to GitHub Apps and never having to generate a long-lived PAT again! 🔐 🚀 + +{% endraw %} diff --git a/_posts/2022-02-08-github-approveops.md b/_posts/2022-02-08-github-approveops.md new file mode 100644 index 0000000..638cb67 --- /dev/null +++ b/_posts/2022-02-08-github-approveops.md @@ -0,0 +1,138 @@ +--- +title: 'ApproveOps: GitHub IssueOps with Approvals' +author: Josh Johanning +date: 2022-02-08 8:30:00 -0600 +description: Using GitHub Actions to build automation on top of Issues (IssueOps) with Approvals from someone in a designated GitHub team +categories: [GitHub, Actions] +tags: [GitHub, GitHub Issues, GitHub Actions, GitHub Apps, IssueOps, ChatOps] +media_subpath: /assets/screenshots/2022-02-08-github-approveops +image: + path: approveops-comments.png + width: 100% + height: 100% + alt: ApproveOps - GitHub IssueOps with Approvals +--- + +## Overview + +_This is follow-up to my previous post, [Demystifying GitHub Apps: Using GitHub Apps to Replace Service Accounts](/posts/github-apps), where I go over the basics of creating a GitHub App and accessing its installation access token in an action_ + +I was working with a customer and we built out a self-service migration solution to migrate from GitHub Enterprise Server to GitHub Enterprise Cloud. Instead of building our own custom interface, we decided to leverage GitHub Issues as the intake and GitHub Actions as the automation mechanism. This is often referred to as "IssueOps". + +There are several great examples of [IssueOps on GitHub](https://github.com/topics/issueops), as well as my co-worker's [ChatOps](https://colinsalmcorner.com/chatops-with-github-actions-and-azure-web-apps/) workflow. + +The benefit of using GitHub and IssueOps for something like this is the transparency of the process - the Issue is available for everyone to see as well as the logs of the Action. The customer just inputs the Git repository URL's and a few other inputs, the issue body is parsed, and a pre-migration comment is automatically posted back to the issue by our bot with a slash command that is used to trigger the migration. + +However, a requirement for the customer was to have a way to approve the migration. We could have had an [approval on an environment on the job](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment), but that interface brought you away from the issue. For example, after someone initiates a deployment, the requestor doesn't necessarily know it's just sitting for an approval. While the approver(s) would get an email and optionally a push notification in the GitHub mobile app, it doesn't seem there is anything that shows up under the [GitHub notifications bell](https://docs.github.com/en/account-and-profile/managing-subscriptions-and-notifications-on-github/setting-up-notifications/about-notifications). + +If only there was a way to only allow the deployment if someone authorized issued some sort of command ahead of time in the issue... This is where ApproveOps comes in! + +## The Solution: ApproveOps + +As I hinted, ApproveOps is a simple extension of IssueOps that requires a slash command in the issue comment body from someone in the 'migration approval' team. In our solution, the slash command to run the deployment was `/run-migration` and our approval command we used was `/approve`. + +If a user runs the migration command without someone approving ahead of time, a bot will comment on the issue saying that it isn't yet approved and to have someone in the approval team approve the migration by entering in the approval command. If someone who isn't in the specified approval team tries to approve the migration, their approval comment will simply be ignored because they aren't in the approval team in GitHub. + +Here's how it looks and works in the issue: + +![ApproveOps](approveops.png ){: .shadow } +_ApproveOps sample - the `/run-migration` command doesn't run the workflow unless someone authorized has commented `/approve`_ + +And here is how one of the runs looks in GitHub Actions: + +![ApproveOps Action Run](approveops-action-run.png ){: .shadow } +_The ApproveOps run in GitHub Actions - the migration job is skipped if no one has approved in the issue yet_ + +## The Code + +I recently created a [GitHub Action published on the marketplace](https://github.com/marketplace/actions/approveops-approvals-in-issueops) that consolidates the various actions and bash commands. If you're not interested in using the marketplace action or want to extend what I've done, you can see the [logic](https://github.com/joshjohanning/approveops/blob/main/action.yml#L39) in the composite action in its entirety. + +Here's how you can use my Action in a GitHub Action workflow ([link to sample YML](https://github.com/joshjohanning-org/approveops-action-validating/blob/main/.github/workflows/approveops.yml)): + +{% raw %} +```yml +name: ApproveOps +on: + issue_comment: + types: [created] + +jobs: + approveops: + runs-on: ubuntu-latest + # only run the job if the comment body contains the command proper command + if: contains(github.event.comment.body, '/do-stuff') + # optional - if we want to use the output to determine if we run the migration job or not + outputs: + approved: ${{ steps.check-approval.outputs.approved }} + + steps: + # get the app's installation token (required for v2) + - uses: tibdex/github-app-token@v1 + id: get_installation_token + with: + app_id: 170284 + private_key: ${{ secrets.APP_PRIVATE_KEY }} + + # V2 - GitHub APp logic pulled out so you can use different action or PAT + - name: ApproveOps - ApproveOps in IssueOps + uses: joshjohanning/approveops@v2 + id: check-approval + with: + approve-command: '/approved' + token: ${{ steps.get_installation_token.outputs.token }} # use a github app token or a PAT + team-name: ${{ env.approver_team_name }} # The name of the team in GitHub to check for the approval command; e.g.: approver-team + fail-if-approval-not-found: true # Fail the action (show the action run as red) if the command is not found in the comments from someone in the approver team" + post-successful-approval-comment: true # Boolean whether to post successful approval comment + successful-approval-comment: ':tada: You were able to run the workflow because someone left an approval in the comments!! :tada:' # Comment to post if there is an approval is found + + # V1 - GitHub App logic baked in + # - name: ApproveOps - ApproveOps in IssueOps + # uses: joshjohanning/ApproveOps@v1 + # id: check-approval + # with: + # app-id: 170284 + # app-private-key: ${{ secrets.PRIVATE_KEY }} + # team-name: approver-team + # fail-if-approval-not-found: false + + migration: + runs-on: ubuntu-latest + needs: approveops + # optional - if we want to use the output to determine if we run the migration job or not + if: ${{ steps.approveops.outputs.approved == 'true' }} + + steps: + - run: echo "run migration!" +``` +{: file='.github/workflows/approveops.yml'} +{% endraw %} + +## Setup and Explanation + +### Prerequisites + +- Since accessing team membership is outside the scope of the [GitHub Token](https://dev.to/github/the-githubtoken-in-github-actions-how-it-works-change-permissions-customizations-3cgp), we have to either use our [GitHub App created in this related post](/posts/github-apps/#scenario-2-using-a-github-app-as-a-rich-comment-bot) or create a PAT with `read:org` scope and use it to get the team membership +- At least one member in the approver team, otherwise `jq` won't be able to find the `.[].login` property (this could probably be written more defensively :) ) + +### Explanation + +I am using a [bash script in my composite action](https://github.com/joshjohanning/approveops/blob/main/action.yml#L45:L72) to: + +1. Get a list of users in the approval team +2. Get a list of comments on the issue (note that I am converting the comments to base64 otherwise comments that had spaces in them would throw the loop off - this was a [good resource for explaining that](https://www.starkandwayne.com/blog/bash-for-loop-over-json-array-using-jq/)) +3. Check that the comment issue body contains the `/approve` command +4. If so, check if the user who posted the `/approve` command is in the approval team (from step 1) by using a `grep` command +5. Setting an [output parameter](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter) depending if the migration is authorized to run or not +6. If there aren't any authenticated approvals, I'm also setting a user-friendly [error message](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message) to be helpful when looking at why the action run failed +7. Afterwards, we use the output parameter and `if:` logic to post the write comment on the workflow, either requesting proper approval or informing the user that the migration will now run since it has been approved +8. I'm using additional `if:` logic and the `approveops` jobs' output to determine if the `migration` job should run or not + - As an alternative, I could see one omitting this using a single job with additional `if:` logic on the rest of the migration steps, but this could be messy + - If using the same job and you didn't mind seeing failed runs in the UI because of lack of approvals, you could fail the workflow run by setting `fail-if-approval-not-found: true` + +## Wrap-up + +There's definitely room for improvement here, but I think this is a good starting point for you to get with your own ApproveOps / IssueOps workflow. + +If you do what I'm doing here, by creating your own [GitHub App on your organization](/posts/github-apps#scenario-2-using-a-github-app-as-a-rich-comment-bot) and use that identity to write your comment, it will be able to properly `@team` mention in the comment and everything! + +Report back if you make any enhancements! diff --git a/_posts/2022-03-07-github-container-jobs.md b/_posts/2022-03-07-github-container-jobs.md new file mode 100644 index 0000000..912a6d6 --- /dev/null +++ b/_posts/2022-03-07-github-container-jobs.md @@ -0,0 +1,133 @@ +--- +title: 'Docker Container Jobs in GitHub Actions' +author: Josh Johanning +date: 2022-03-07 13:30:00 -0600 +description: Getting started using a Docker Container to run your GitHub Actions Job, tips and tricks, troubleshooting, and caveats +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, Docker, Containers, Actions Runner Controller] +media_subpath: /assets/screenshots/2022-03-07-github-container-jobs +image: + path: container-job-post-image.png + width: 100% + height: 100% + alt: Container Job in GitHub +--- + +## Overview + +GitHub Actions has a relatively little known feature where you can [run jobs in a container](https://docs.github.com/en/actions/using-jobs/running-jobs-in-a-container), specifically a Docker container. I liken it to delegating the entire job to the container, so every step that would run in the job will instead run in the container, including the clone/checkout of the repository. This generally works well, but there are some tips and tricks that I can pass along that may be helpful, especially if running in a self-hosted runner scenario. + +Why would you want to use a container job, you may ask? Well imagine you have a Python application that uses a specific version of Python. Okay, simple enough, we can just use the [Setup Python](https://github.com/marketplace/actions/setup-python) action to install the right version. But what if we also require a specific / non-standard version of Node? And MySQL? We could use a script and install all our prerequisites using `apt install`, but this takes time. Over dozens of CI jobs, the extra minutes add up and you might even run against the cap of your limit. So instead, we can use a container job that has all the prerequisites our application needs to build / run already pre-installed. + +I won't really be covering it, but there is also the option to run a [service container alongside your job](https://docs.github.com/en/actions/using-containerized-services/about-service-containers). This would be useful if running tests against a containerized copy of a database, for example. The documentation uses Redis as an example. Similar with [Docker Container Actions](https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action). + +## Caveats + +Usually, I put the caveats after the implementation, but there are enough important ones here to lead with. If none of these apply, head to the [Implementation](#implementation) section. + +- Containers in GitHub Actions, including [Container Jobs](https://docs.github.com/en/actions/using-jobs/running-jobs-in-a-container), [Service Containers](https://docs.github.com/en/actions/using-containerized-services/about-service-containers), and [Docker Container Actions](https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action) only work on **Linux** runners - they will not run on Windows runners 😔 + + Some Marketplace actions, such as [Checkmarx](https://github.com/marketplace/actions/checkmarx-cxflow-action), are Docker Container Actions, therefore they won't run on Windows + +![Container jobs/actions can't run on windows, macos](container-action-only-windows.png){: .shadow } +_Container jobs/actions can't run on Windows or MacOS_ + +- If you are using Docker to run the runner without doing the docker-in-docker magic, you might see an error - but if you are using something like [actions-runner-controller](https://github.com/actions-runner-controller/actions-runner-controller), this is a non-issue + + Error mentioned in this [issue](https://github.com/actions/runner/issues/367#issuecomment-597742895) + +![Container jobs/actions can't run within another container](container-cant-run-in-container.png){: .shadow } +_Container jobs/actions can't run within another container unless you have docker-in-docker setup_ + +- You cannot override the working directory that gets mapped in - the `/_work/`{: .filepath} directory on the host is mapped to `/__w/`{: .filepath} in the container + + This is only a problem if you had intended to use an alternative work directory with permissions already set up in the container + + We can pass in [additional options](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idcontaineroptions) to use in GitHub for the container job, but Docker our options get added to the end of the original Docker command and subsequent `--workdir` options are ignored + + Mentioned in this [issue](https://github.com/actions/runner/issues/878) + + Relevant errors: + > ``` + > /usr/bin/git init /__w/container-job-test/container-job-test + > /__w/container-job-test/container-job-test/.git: Permission denied + > Error: The process '/usr/bin/git' failed with exit code 1 + > ``` + + and + + > ``` + > Deleting the contents of '/__w/container-job-test/container-job-test' + > Error: Command failed: rm -rf "/__w/container-job-test/container-job-test/.git" + > rm: cannot remove '/__w/container-job-test/container-job-test/.git': Permission denied + > ``` + + + A fix was to `chmod` the `/_work/`{: .filepath} directory on the **host** to [work around this permissions issue](https://github.com/actions/runner/issues/878#issuecomment-1030686369) + +- The default shell for `run` steps inside a container is `sh` instead of `bash`. This can be overridden with [`jobs..defaults.run`](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iddefaultsrun) or [`jobs..steps[*].shell`](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell). + + This is important because bashisms, such as if statements that contain `[[ ]]`, will not work in a `sh` script + + As an example, you might see an `[[: not found` error when running the container job that works when not running as a container job + +## Implementation + +The full syntax for using container jobs, such as specifying ports, volumes, and options, is available [here](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idcontainer). + +In [this example](https://github.com/joshjohanning/container-job-test), I am building my own Docker image, publishing to the repository, and using the image in a subsequent workflow as a container job, shown below: + +{% raw %} + +```yml +name: Container Job + +on: + push: + branches: + main + +env: + test: value + +jobs: + container: + runs-on: ubuntu-latest + container: + # image: 'ubuntu:20.04' # can also use this to test + image: 'ghcr.io/${{ github.repository }}:latest' + credentials: + username: ${{ github.ref }} + password: ${{ secrets.GITHUB_TOKEN }} + env: + actor: ${{ github.actor }} + testjob: here is value + + steps: + - uses: actions/checkout@main + - name: run ls + run: ls + # all these below work + - name: print actor env var + run: echo "$actor" + - name: print directly github context + run: echo "${{ github.actor }}" + - name: print repo secret + run: echo "${{ secrets.TEST_SECRET }}" + - name: print job env var + run: echo "$testjob" + - name: print root env var + run: echo "$test" + - name: condition with root env var + if: ${{ env.test == 'value' }} + run: echo "$test" +``` +{: file='.github/workflows/container-job.yml'} + +{% endraw %} + +When you run the workflow, you will notice an additional log entry to initialize the container. Subsequently, all steps in the job will run inside of the container: + +![Container job](container-job.png){: .shadow } +_Successful container job_ + +For those wondering, here is what a sample `DOCKERFILE`{: .filepath} for this [looks like](https://github.com/joshjohanning/container-job-test/blob/main/Dockerfile) - hint there's nothing special. You can also test this with good 'ol `ubuntu:20.04` (or `ubuntu:latest`). + +Perhaps more interestingly, my workflow for publishing my Docker image is [here](https://github.com/joshjohanning/container-job-test/blob/main/.github/workflows/docker-image.yml#L34). + +## Summary + +I've used Container Jobs in Azure DevOps before, and I was excited to see we had similar functionality in GitHub! This can be much more practical than writing a large script to `apt install` everything for each job run. Just note some of the [caveats](#caveats), most of which only apply to self-hosted and non-Linux runners. + +You can take this to the next step, instead of running your jobs in containers, you could additionally run your runners in containers using something like [actions-runner-controller](https://github.com/actions-runner-controller/actions-runner-controller). actions-runner-controller, which is running a runner in a container in k8s, supports running container jobs with Docker actions no problem! diff --git a/_posts/2022-03-08-github-advanced-security-permissions-chart.md b/_posts/2022-03-08-github-advanced-security-permissions-chart.md new file mode 100644 index 0000000..22e7d1f --- /dev/null +++ b/_posts/2022-03-08-github-advanced-security-permissions-chart.md @@ -0,0 +1,85 @@ +--- +title: 'GitHub Advanced Security Permissions Chart' +author: Josh Johanning +date: 2022-03-08 12:00:00 -0600 +description: An access requirements/permissions comparison between various roles in GitHub Enterprise and GitHub Advanced Security, such as what users with Write access to the repository get vs. what requires elevated privileges +categories: [GitHub, Advanced Security] +tags: [GitHub, GitHub Advanced Security, Dependabot] +media_subpath: /assets/screenshots/2022-03-08-github-advanced-security-permissions-chart +image: + path: ../2023-02-28-security-alerts/security-overview-dark.png + width: 100% + height: 100% + alt: Security Overview for an Organization +pin: false +--- + +## Overview + +I have several [posts discussing GitHub Advanced Security](/tags/github-advanced-security/), but practically a question that I get often is: _"Who can access the alerts on each repository?"_ + +I hope to solve that with this permissions / access requirements chart! + +See also: [GitHub Advanced Security Feature Comparison](/posts/github-advanced-security-feature-chart/) + +## Access requirements for security features + +This chart is loosely based on [the one from GitHub](https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-access-to-your-organizations-repositories/repository-roles-for-an-organization), with a few additions, modifications, and clarifications. + +| Feature | Read[1] | Write,Mntn [2] | Admin | Sec. Mgr | Org Owner | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------:|:---------------:|:---------------:|:---------------:|:----------------:| +| [Receive Dependabot alerts](https://docs.github.com/en/enterprise-cloud@latest/code-security/supply-chain-security/about-alerts-for-vulnerable-dependencies) | | ✔️ | ✔️ | ✔️ | ✔️ | +| [Dismiss Dependabot alerts](https://docs.github.com/en/enterprise-cloud@latest/code-security/supply-chain-security/viewing-and-updating-vulnerable-dependencies-in-your-repository) | | ✔️ | ✔️ | ✔️ | ✔️ | +| [Designate others to receive security alerts](https://docs.github.com/en/enterprise-cloud@latest/github/administering-a-repository/managing-security-and-analysis-settings-for-your-repository#granting-access-to-security-alerts) | | | ✔️ | ✔️ | ✔️ | +| [Create security advisories](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-advisories/about-github-security-advisories) | | | ✔️ | ✔️ | ✔️ | +| [Manage access to GHAS features in the repo](https://docs.github.com/en/enterprise-cloud@latest/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-security-and-analysis-settings-for-your-repository) | | | ✔️ | ✔️ | ✔️ | +| [Enable the dependency graph](https://docs.github.com/en/enterprise-cloud@latest/code-security/supply-chain-security/understanding-your-software-supply-chain/exploring-the-dependencies-of-a-repository) | | | ✔️ | ✔️ | ✔️ | +| [View dependency reviews](https://docs.github.com/en/enterprise-cloud@latest/code-security/supply-chain-security/about-dependency-review) | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| [View code scanning alerts on pull requests](https://docs.github.com/en/enterprise-cloud@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/triaging-code-scanning-alerts-in-pull-requests) | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| [Manage code scanning alerts](https://docs.github.com/en/enterprise-cloud@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/managing-code-scanning-alerts-for-your-repository) | | ✔️ | ✔️ | ✔️ | ✔️ | +| [View secret scanning alerts in a repository](https://docs.github.com/en/enterprise-cloud@latest/github/administering-a-repository/managing-alerts-from-secret-scanning) | | ⚠️[3] | ✔️ | ✔️ | ✔️ | +| [Manage secret scanning alerts](https://docs.github.com/en/enterprise-cloud@latest/github/administering-a-repository/managing-alerts-from-secret-scanning) | | ⚠️[3] | ✔️ | ✔️ | ✔️ | +| [Access to the org's security overview](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-overview/about-the-security-overview) | | ✔️[4] | ✔️[4] | ✔️ | ✔️ | +| [Access to the enterprise's security overview](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-overview/about-the-security-overview) | | | | ✔️[5] | ✔️[5] | +| [Manage GHAS features at org level](https://docs.github.com/en/enterprise-cloud@latest/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/managing-security-and-analysis-settings-for-your-organization) | | | | ✔️ | ✔️ | +| [Designate Security Managers](https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-peoples-access-to-your-organization-with-roles/managing-security-managers-in-your-organization) | | | | | ✔️ | +| Read access to repo(s) | ✔️ | ✔️ | ✔️ | ✔️[6] | ✔️ | +| Write access to repo(s) | | ✔️ | ✔️ | | ✔️ | + +Notes: + +- [1] **Read** and **Triage** have the same rights for security features +- [2] **Write** and **Maintain** have the same rights for security features +- [3] Repository **writers** and **maintainers** can only see secret alert information for their own commits, but **only as a direct link to the secret scanning alert sent via email** ⚠️ +- [4] Now that the [org-level security overview is available to all Enterprise users](https://github.blog/changelog/2022-08-08-security-overview-is-now-available-to-all-github-enterprise-users/), org members can see consolidated results of repositories that they can see alerts for (e.g., **write** for CodeQL and Dependabot, **admin** for secrets) +- [5] In the [enterprise-level security overview](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-overview/about-the-security-overview) level, one would see organizations where they are added as an **org owner** or **security manager** - **enterprise owners** must [join an organization as an owner](https://docs.github.com/en/enterprise-cloud@latest/admin/user-management/managing-organizations-in-your-enterprise/managing-your-role-in-an-organization-owned-by-your-enterprise) to see alerts +- [6] **Security managers** get read-only access to every repository in the organization +- This chart primarily focuses on **GitHub Enterprise Cloud**, but note that Advanced Security is available for GitHub Enterprise Server [3.0 or higher](https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security). There may be slight differences in the features available for GitHub Enterprise Server based on the version + +## Granting access to security alerts + +Security alerts for a repository are visible to people with admin access to the repository and, when the repository is owned by an organization, organization owners. You can also [give additional teams and people access to the alerts](https://docs.github.com/en/enterprise-cloud@latest/github/administering-a-repository/managing-security-and-analysis-settings-for-your-repository#granting-access-to-security-alerts). + +When [adding users to be able to view security alerts](https://docs.github.com/en/enterprise-cloud@latest/github/administering-a-repository/managing-security-and-analysis-settings-for-your-repository#granting-access-to-security-alerts), there is a bit of text that explains (emphasis mine): + +> Admins, users, and teams in the list below have permission to **view and manage code scanning, Dependabot, or secret scanning alerts**. These users may be notified when a new vulnerability is found in one of this repository's dependencies and when a secret or key is checked in. They will also see additional details when viewing Dependabot security updates. Individuals can manage how they receive these alerts in their [notification settings](https://github.com/settings/notifications). + +Note: Organization owners and repository administrators can only grant access to view security alerts, such as secret scanning alerts, to people or teams who have **write** access to the repo. + +## Custom Repository Roles + +Organization administrators can create [Custom Repository Roles](https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-peoples-access-to-your-organization-with-roles/managing-custom-repository-roles-for-an-organization) to customize and fine-tune different permission sets that repository administrators can grant. For example, I want to create a role that allows users to have Write access AND be able to view/dismiss Dependabot Alerts: + +![Custom Repository Roles](custom-roles.png){: .shadow } +_Custom repository roles - creating a custom role to allow viewing/managing of Dependabot alerts_ + +Note that there is currently a maximum of 3 custom roles that can be created in the organization. + +## Changelog + +| Date | Note | +|-------------|--------------------------------------| +| Apr 26 2023 | Write and Maintain can [view/manage Dependabot alerts now](https://github.blog/changelog/2023-02-07-dependabot-alerts-default-permissions-change/) (GHES 3.9+) | +| Oct 11 2022 | Removing Beta from [Security Overview for the Org](https://github.blog/changelog/2022-04-07-security-overview-for-organizations-is-generally-available/),
[Security Overview is available to all GitHub Enterprise customers](https://github.blog/changelog/2022-08-08-security-overview-is-now-available-to-all-github-enterprise-users/),
Consolidated Read/Triage and Write/Maintain since they have the same security permissions | +| Mar 11 2021 | Adding section about security alerts | +| Mar 08 2021 | Initial post | diff --git a/_posts/2022-03-09-github-codeql-ignore-files.md b/_posts/2022-03-09-github-codeql-ignore-files.md new file mode 100644 index 0000000..958040b --- /dev/null +++ b/_posts/2022-03-09-github-codeql-ignore-files.md @@ -0,0 +1,121 @@ +--- +title: 'Ignore Files in GitHub CodeQL Analysis' +author: Josh Johanning +date: 2022-03-08 12:00:00 -0600 +description: Exclude file(s) from the results of GitHub's CodeQL Analysis tool (part of GitHub Advanced Security) +categories: [GitHub, Advanced Security] +tags: [GitHub, GitHub Advanced Security, CodeQL] +media_subpath: /assets/screenshots/2022-03-09-github-codeql-ignore-files +--- + +## Overview + +I was recently working with a customer and we flipped on the `security-and-quality` [query suite](https://docs.github.com/en/enterprise-cloud@latest/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#running-additional-queries) and received a _a lot_ of results, mostly in our tests. We wanted a way to ignore these files for the purposes of the CodeQL analysis. One could argue that these should be scanned and acted on too, but you have to start somewhere, right? And there are plenty of use cases where might want to ignore a file for the purposes of the CodeQL analysis. + +There are a few ways to do this, one through a filter in the UI, and another with actions. I will show you both below! + +## Filter out tests in the UI + +You might notice that some of the queries have the phrase _(Test)_ before the file name. CodeQL tries to determine which files are tests and which are application code automatically: + +![CodeQL - Tests](codeql-test.png){: .shadow } +_CodeQL result found in a test file_ + +You can filter out *Test* results in the UI by adding the `autofilter:true` filter in the search bar: + +![Filtering out CodeQL vulnerabilities in test files with autofilter](codeql-test-autofilter.png){: .shadow } +_Filtering out CodeQL vulnerabilities in test files with autofilter_ + +You cannot use `autofilter:false` to filter *only* test results, however. + +## Exclude files using an action + +I found the [filter-sarif](https://github.com/zbazztian/filter-sarif) action that did just what the team wanted to do - filter out all `**/*test*.js`{: .filepath} files! The action isn't published on the marketplace (yet!), but it is a public GitHub repository, therefore we can use it just like we can any other action that is published to the marketplace. + +The [docs](https://github.com/zbazztian/filter-sarif#patterns) reference some cool patterns you can use - such as ignoring all tests, but even inside of those tests, still report `sql-injection` vulnerabilities. You can find the ID for this by referencing the [CodeQL Query Help](https://codeql.github.com/codeql-query-help/) page, selecting your [language](https://codeql.github.com/codeql-query-help/javascript/), selecting the [query](https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/), and find the `ID` reference. Alternatively, you can dig into the [CodeQL GitHub repoistory](https://github.com/github/codeql) to find the [query](https://github.com/github/codeql/blob/main/javascript/ql/src/Security/CWE-089/SqlInjection.ql) and reference the `@ID` value. + +Here's an example: + +{% raw %} + +```yml +name: "CodeQL" + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: '43 22 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'javascript' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + upload: false # disable the upload here - we will upload in a different action + output: sarif-results + + - name: filter-sarif + uses: advanced-security/filter-sarif@v1 + with: + # filter out all test files unless they contain a sql-injection vulnerability + patterns: | + -**/*test*.js + +**/*test*.js:js/sql-injection + input: sarif-results/${{ matrix.language }}.sarif + output: sarif-results/${{ matrix.language }}.sarif + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: sarif-results/${{ matrix.language }}.sarif + + # optional: for debugging the uploaded sarif + - name: Upload loc as a Build Artifact + uses: actions/upload-artifact@v3 + with: + name: sarif-results + path: sarif-results + retention-days: 1 +``` +{: file='.github/workflows/codeql-analysis.yml'} + +{% endraw %} + +If you couldn't tell by the design of the workflow, this isn't necessarily _skipping_ these files as part of the scan - it simply strips them out from the `.sarif`{: .filepath} that's being uploaded to GitHub. + +After running, you will see that the vulnerability in the excluded test file is now marked automatically as _closed:_ + +![Closed CodeQL vulnerabilities](codeql-closed.png){: .shadow } +_CodeQL result marked as closed after we are excluding test files_ + +If we click into the vulnerability, we also see the history. For example, I was testing this workflow so it opened and closed a few times: + +![CodeQL vulnerability history](codeql-history.png){: .shadow } +_CodeQL result history - when it was opened, closed, and reappeared_ + +## Summary + +You can either filter out non-application code in the UI or using an action to exclude certain files based on a pattern. Happy scanning! diff --git a/_posts/2022-03-10-github-dependabot-with-azure-artifacts.md b/_posts/2022-03-10-github-dependabot-with-azure-artifacts.md new file mode 100644 index 0000000..ba0ca7b --- /dev/null +++ b/_posts/2022-03-10-github-dependabot-with-azure-artifacts.md @@ -0,0 +1,157 @@ +--- +title: 'Use Dependabot in GitHub with Azure Artifacts' +author: Josh Johanning +date: 2022-03-10 8:00:00 -0600 +description: Using Dependabot to update your dependencies that are hosted in Azure Artifacts +categories: [GitHub, Dependabot] +tags: [GitHub, Dependabot, Azure Artifacts, Azure DevOps, Pull Requests] +media_subpath: /assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts +image: + path: dependabot-pr.png + width: 100% + height: 100% + alt: Pull request from Dependabot with package residing in Azure Artifacts +--- + +## Overview + +If you have heavy investment in Azure Artifacts, it can be hard to fully transition to [GitHub Packages](https://docs.github.com/en/packages/learn-github-packages/introduction-to-github-packages). However, there is a bit of a transition. In GitHub, while you can see a list of packages the [organization level](https://docs.github.com/en/packages/learn-github-packages/viewing-packages#viewing-an-organizations-packages), the packages are installed _to a specific repository._ For further detail, here are the instructions for pushing various package ecosystems to GitHub: +- [npm](https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages) +- [NuGet](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-nuget-registry) +- [Maven](https://docs.github.com/en/actions/publishing-packages/publishing-java-packages-with-maven) +- [Docker](https://docs.github.com/en/actions/publishing-packages/publishing-docker-images) + +Alright but you might be thinking, if I'm not using GitHub Packages, won't Dependabot not work then? Well, no. [Dependabot](https://github.blog/2020-06-01-keep-all-your-packages-up-to-date-with-dependabot/) is not just for keeping your public packages up to date - Dependabot also supports private feeds, including Azure Artifacts! + +## Configuration + +For this to work, you just have to set up a [Dependabot secret](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/managing-encrypted-secrets-for-dependabot). I called my secret `AZURE_DEVOPS_PAT` below. + +Here is the full `.github/dependabot.yml`{: .filepath} configuration: + +{% raw %} + +```yml +version: 2 +registries: + npm-azure-artifacts: + type: npm-registry + url: https://pkgs.dev.azure.com/jjohanning0798/PartsUnlimited/_packaging/npm-example/npm/registry/ + username: jjohanning0798 + password: ${{ secrets.AZURE_DEVOPS_PAT }} # Must be an unencoded password +updates: + - package-ecosystem: "npm" + directory: "/" + registries: + - npm-azure-artifacts + schedule: + interval: "daily" +``` +{: file='.github/dependabot.yml'} + +{% endraw %} + +## Confirming it works + +Shortly after committing the `.dependabot.yml`{: .filepath} file, we can confirm it works as there's a new PR from Dependabot: +![Dependabot logs](dependabot-pr.png){: .shadow } +_Pull request created by Dependabot_ + +We can also look at our [Dependabot logs](https://docs.github.com/en/code-security/supply-chain-security/managing-vulnerabilities-in-your-projects-dependencies/troubleshooting-dependabot-errors#investigating-errors-with-dependabot-version-updates): + +![Dependabot logs](dependabot-logs.png){: .shadow } +_Dependabot logs showing that there is a new package version from Azure Artifacts_ + + +## Troubleshooting + +### Don't use token with Azure DevOps + +If you follow the [Dependabot documentation for NuGet](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#nuget-feed) that's there today, for example, it has you use a `token` property instead of `username` and `password`: + +{% raw %} + +```yml +registries: + nuget-azure-devops: + type: nuget-feed + url: https://pkgs.dev.azure.com/.../_packaging/My_Feed/nuget/v3/index.json + token: ${{secrets.MY_AZURE_DEVOPS_TOKEN}} # this doesn't work +``` +{: file='.github/dependabot.yml'} + +{% endraw %} + +If you check your [Dependabot logs](https://docs.github.com/en/code-security/supply-chain-security/managing-vulnerabilities-in-your-projects-dependencies/troubleshooting-dependabot-errors#investigating-errors-with-dependabot-version-updates), you will probably see `401` or `private_source_authentication_failure` errors. This is because Azure Artifacts needs to use basic authentication, which using the `username` and `password` fields provide. The username isn't used, but the password has to be an unencoded personal access token. + +{% raw %} + +```yml +registries: + nuget-azure-devops: + type: nuget-feed + url: https://pkgs.dev.azure.com/.../_packaging/My_Feed/nuget/v3/index.json + username: octocat@example.com + password: ${{secrets.MY_AZURE_DEVOPS_TOKEN}} # this works +``` +{: file='.github/dependabot.yml'} + +{% endraw %} + +Alternatively, you could still use `token`, but just append a `:` at the end of the PAT as mentioned in this [issue here](https://github.com/dependabot/dependabot-core/issues/3555). + +### Pull request limit + +Another reason you might not be seeing your pull request from an outdated dependency in Azure Artifacts is if the pull request limit is not defined. By default, the limit is 5, so Dependabot will only create 5 pull requests for version updates as to not inundate you. If you check your pull requests, you might see you have more than 5, but some of those might be Dependabot Security Alerts, which don't count to that limit. + +See the [docs](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates), but here's an example (see: `open-pull-requests-limit` on line 15): + +{% raw %} + +```yml +version: 2 +registries: + npm-azure-artifacts: + type: npm-registry + url: https://pkgs.dev.azure.com/jjohanning0798/PartsUnlimited/_packaging/npm-example/npm/registry/ + username: jjohanning0798 + password: ${{ secrets.AZURE_DEVOPS_PAT }} # Must be an unencoded password +updates: + - package-ecosystem: "npm" + directory: "/" + registries: + - npm-azure-artifacts + schedule: + interval: "daily" + open-pull-requests-limit: 15 +``` +{: file='.github/dependabot.yml'} + +{% endraw %} + +### Dependabot misconfiguration + +If you have any other misconfiguration, such as the registry names not matching, you will be able to see from the [Dependabot logs](https://docs.github.com/en/code-security/supply-chain-security/managing-vulnerabilities-in-your-projects-dependencies/troubleshooting-dependabot-errors#investigating-errors-with-dependabot-version-updates) as well. Here's an example of such an error where the two registry names didn't match: + +> The property '#/updates/0/registries' includes the "nuget-azure-artifacts" registry which is not defined in the top-level 'registries' definition + +See the [docs](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates) for the configuration syntax and examples. + +### Re-running Dependabot + +Even though you might have the schedule set to "daily", Dependabot will run again if you push a change to the `.github/dependabot.yml`{: .filepath}. You can also run it manually at any time by navigating to: + +1. Insights +2. Dependency Graph +3. Dependabot +4. Click into the last run, e.g.: "last checked 16 hours ago" +5. Check for updates + +![Manually run Dependabot again](dependabot-update.png){: .shadow } +_Check for Dependabot updates again manually_ + +## Summary + +Being able to use Dependabot with Azure Artifacts is a great way to keep your internally-created packages up to date. Teams can be notified automatically that there's a new version of the package available and after a successful build with passing unit tests, can accept and merge the PR. If a team doesn't want to use the updated version, they can simply close the PR and it won't be re-opened until a new version of the package is released. I always prefer to at least be notified of new versions, so I think this is awesome! + +If the emails become too much, you can always modify your [notification settings](https://docs.github.com/en/account-and-profile/managing-subscriptions-and-notifications-on-github/setting-up-notifications/configuring-notifications) 😀. diff --git a/_posts/2022-03-11-migrate-azure-devops-work-items-to-github-issues.md b/_posts/2022-03-11-migrate-azure-devops-work-items-to-github-issues.md new file mode 100644 index 0000000..9b1272d --- /dev/null +++ b/_posts/2022-03-11-migrate-azure-devops-work-items-to-github-issues.md @@ -0,0 +1,23 @@ +--- +title: 'Migrate Azure DevOps Work Items to GitHub Issues' +author: Josh Johanning +date: 2022-03-11 4:00:00 -0600 +description: A PowerShell script to migrate Azure DevOps Work Items to GitHub Issues +categories: [GitHub, Migrations] +tags: [GitHub, GitHub Issues, Azure DevOps, Azure Boards, Work Items, Scripts, Migrations] +media_subpath: /assets/screenshots/2022-03-11-migrate-azure-devops-work-items-to-github-issues +--- + +## Overview + +Quick post since most of this is in the [README in the repo](https://github.com/joshjohanning/ado_workitems_to_github_issues), but I created a Powershell script to migrate Azure DevOps work items to GitHub Issues. It's certainly not perfect, but there wasn't anything else out there I could find. If you do find something better, please do let me know! + +The repo: [https://github.com/joshjohanning/ado_workitems_to_github_issues](https://github.com/joshjohanning/ado_workitems_to_github_issues) + +## Example + +[Link to example of migrated issue in GitHub](https://github.com/joshjohanning-org/migrate-ado-workitems/issues/291) + +Screenshot: +![Azure DevOps work item migrated to GitHub Issues](migrated-issue.png){: .shadow } +_Example of work item migrated from Azure DevOps to GitHub Issues_ diff --git a/_posts/2022-03-28-gh-auth-login-in-actions.md b/_posts/2022-03-28-gh-auth-login-in-actions.md new file mode 100644 index 0000000..e99ff26 --- /dev/null +++ b/_posts/2022-03-28-gh-auth-login-in-actions.md @@ -0,0 +1,100 @@ +--- +title: 'How to use gh auth login CLI Programmatically in GitHub Actions' +author: Josh Johanning +date: 2022-03-28 12:00:00 -0500 +description: Using the gh cli to programmatically authenticate in GitHub Actions +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, gh cli] +--- + +## Overview + +Quick post here since I have to search for this every time I try to use the [`gh cli`](https://cli.github.com/) in GitHub Actions. In order to use the `gh cli`, you typically have to run `gh auth login` to authenticate. If you are running this from an interactive session (ie: on your local machine), you are provided with some prompts to easily authenticate to GitHub.com or GitHub Enterprise Server. If you try to do this from an command in a GitHub Actions, the action will just stall out and you will have to cancel since `gh auth login` is intended to be done in an interactive session. + +{% raw %} + +There is a [`gh auth login --with-token`](https://cli.github.com/manual/gh_auth_login) in the docs that provides an example for reading from a file, but if you're running in a GitHub Action workflow, your `${{ secrets.GITHUB_TOKEN }}` isn't going to be a file. + +## Example 1 - gh auth login + +Here's an example GitHub Action sample for logging into the `gh cli` and using [`gh api`](https://cli.github.com/manual/gh_api) to retrieve a repositories topics: + +```yml + steps: + - run: | + echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token + gh api -X GET /repos/${{ GITHUB.REPOSITORY }}/topics --jq='.names' +``` + +This works, but there's a better way that doesn't require running a `gh auth login` command at all. + +## Example 2 - env variable + +✨ If you try to run a `gh` command without authenticating, you will see the following error message: + +> gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example: +> ```yml +> env: +> GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +> ``` + +With this, you will notice you don't have to run `gh auth login` at all. You can just set the `GH_TOKEN` environment variable to the value of `${{ secrets.GITHUB_TOKEN }}` and the `gh cli` will use that token to authenticate. You can set the environment variable at the [workflow](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#env) level, [job](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idenv) level, or [step](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsenv) level. + +This is an example of the least privilege approach, setting the `env` variable at the [step](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsenv) level, and allowing different steps to use different tokens if needed: + +```yml + steps: + - run: gh issue create --title "My new issue" --body "Here are more details." + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +If you are using `gh cli` in multiple steps or jobs in a workflow, setting the `GH_TOKEN` as a [workflow](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#env) (or https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#env) level, [job](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idenv) [`env`] variable might be more efficient: + +```yml +env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # setting GH_TOKEN for the entire workflow + +jobs: + prebuild: + runs-on: ubuntu-latest + # env: + # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # setting GH_TOKEN for the entire job + steps: + - run: | + gh api -X GET /repos/${{ GITHUB.REPOSITORY }}/topics --jq='.names' + build: + runs-on: ubuntu-latest + steps: + - run: | + gh api -X GET /repos/${{ GITHUB.REPOSITORY }}/branches --jq='.[].name' +``` + +## Example 3 - Authenticate with GitHub App + +This example combines concepts learned in this post with the [*Demystifying GitHub Apps: Using GitHub Apps to Replace Service Accounts*](/posts/github-apps/) post. + +You may want to use a GitHub app to authenticate and use the `gh cli` in a GitHub Action workflow to do something. You can manage permissions more with the GitHub App, and installing it on the org / granting access to multiple repositories whereas `${{ secrets.GITHUB_TOKEN }}` only has access to resources inside of the repository running the action. In addition, you can give the actor a more meaningful name (e.g.: `PR-Enforcer-Bot`) vs. the default `github-actions[bot]` user. + +Here's an example that uses an app to create an issue in a *different* repository: + +```yml + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + # optional: owner not needed IF the app has access to the repo running the workflow + # if you get 'RequestError [HttpError]: Not Found 404', pass in owner + owner: ${{ github.repository_owner }} + + - name: Create Issue + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh issue create --title "My new issue" --body "Here are more details." \ + -R my-org/my-repo +``` + +{% endraw %} diff --git a/_posts/2022-04-06-dependency-review-action.md b/_posts/2022-04-06-dependency-review-action.md new file mode 100644 index 0000000..2c712b7 --- /dev/null +++ b/_posts/2022-04-06-dependency-review-action.md @@ -0,0 +1,71 @@ +--- +title: 'GitHub: Block Pull Requests if a Vulnerable Dependency is Added' +author: Josh Johanning +date: 2022-04-06 15:00:00 -0500 +description: Block Pull Requests in GitHub if you add a vulnerable dependency/package version +categories: [GitHub, Advanced Security] +tags: [GitHub, GitHub Advanced Security, GitHub Actions, Pull Requests, Policy Enforcement, Branch Protection Rules] +media_subpath: /assets/screenshots/2022-04-06-dependency-review-action +image: + path: dependency-review-action.png + width: 100% + height: 100% + alt: Dependency Review action +--- + +## Overview + +GitHub has added a [new Dependency Review action](https://github.com/actions/dependency-review-action) to help keep vulnerable dependencies out of your repository! One of the complaints with the way Dependabot Security Alerts works in GitHub is that it only works from the default branch. As a result, you aren't alerted that you are adding a vulnerable package until after you have already merged to the default branch. The previous solution to this was the [Dependency Review (rich diff)](https://github.blog/changelog/2021-10-05-dependency-review-is-generally-available/) in a pull request, but this was slightly hidden and there was no enforcement capabilities. + +Note that the [new Dependency Review action](https://github.com/actions/dependency-review-action) still requires a GitHub Advanced Security license, as mentioned in the [GitHub Changelog blog post](https://github.blog/changelog/2022-04-06-github-action-for-dependency-review-enforcement/): +> The dependency review action is available for use in public repositories. The action is also available in private repositories owned by organizations that use GitHub Enterprise Cloud and have a license for GitHub Advanced Security. + +## Dependency Review Action + +The action is relatively [simple](https://github.com/actions/dependency-review-action) (no inputs as of yet) - and here's some [additional documentation](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement). + +```yml +name: 'Dependency Review' +on: [pull_request] + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v3 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v1 +``` +{: file='.github/workflows/dependency-review.yml'} + +## Results + +To try this at home, you can attempt to `"tar": "2.2.2"` to the `dependencies` section of your `package.json`{: .filepath} file. This will cause the action to fail since there are several vulnerabilities in this version of `tar`: + +![Attempt at adding a vulnerable dependency](dependency-review-action.png){: .shadow } +_Dependency Review Action preventing a pull request with a vulnerable dependency added_ + +I think this is _much_ better than the prior option for finding/preventing vulnerable dependencies in a pull request: + +![Rich diff of dependencies in a pull request](dependency-review-rich-diff.png){: .shadow } +_The previous option for dependency review in a pull request (rich diff)_ + +## Making this a required status check + +## How To + +To make this a required status check on the pull request, follow these instructions: + +1. The first thing you need is a public repository (GHAS is free for public repos) or a private repository with the GitHub Advanced Security license enabled +1. Under the Settings tab in the repository, navigate to Branches +1. Create a [branch protection rule](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule#creating-a-branch-protection-rule) for your default branch - check the 'Require status checks to pass before merging' box +1. Add the dependency review job as a status check - in the example above, it's `dependency-review` + - If you don't see the `dependency-review` to add as a status check to the branch protection, **it won't appear as an option until you initiate at least one PR on the repository that triggers this job**. + +![Status checks required for a pull request](pull-request-status-checks.png){: .shadow } +_Branch Protection Policy with the dependency-review status check configured_ + +## Summary + +This [new Dependency Review action](https://github.com/actions/dependency-review-action) uses the [dependency review API endpoint](https://docs.github.com/en/rest/reference/dependency-graph#dependency-review) to determine if you are adding a new vulnerable package version to your codebase. It doesn't catch/block if there are _any_ vulnerable dependencies, only dependencies added/modified in the pull request. diff --git a/_posts/2022-05-27-github-delete-branch-protection-rules.md b/_posts/2022-05-27-github-delete-branch-protection-rules.md new file mode 100644 index 0000000..1a01d73 --- /dev/null +++ b/_posts/2022-05-27-github-delete-branch-protection-rules.md @@ -0,0 +1,121 @@ +--- +title: 'Delete GitHub Branch Protection Rules Programmatically' +author: Josh Johanning +date: 2022-05-27 12:00:00 -0500 +description: Delete GitHub Branch Protection Rules from a PowerShell script +categories: [GitHub, Scripts] +tags: [GitHub, Branch Protection Rules, Scripts] +--- + +## Overview + +After a migration, or maybe when doing cleanup, you may want to delete branch protection rules in bulk. Instead of having to click through each branch protection rule individually, I wrote a PowerShell script that leverages the GraphQL endpoint. At the time I wrote this a few years ago, there wasn't an API for deleting branch protection rules, only GraphQL. However, there is now an [API endpoint for managing branch protection rules](https://docs.github.com/en/rest/branches/branch-protection), so if I were to re-write this, that's likely what I would use. + +But this script works just fine as is to delete branch protection rules programmatically, and I thought it was time to share it! + +## Script + +The script is also located [here](https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/delete-branch-protection-rules.ps1). + +```powershell +############################################################## +# Delete branch protection rules +############################################################## + +[CmdletBinding()] +param ( + [parameter (Mandatory = $true)][string]$PersonalAccessToken, + [parameter (Mandatory = $true)][string]$GitHubOrg, + [parameter (Mandatory = $true)][string]$GitHubRepo, + [parameter (Mandatory = $true)][string]$PatternToDelete + # If you want to delete branch protection rules that start with "feature", pass in "feature*" + # If you want to delete ALL branch protection rules, pass in "*" +) + +# Example that deletes ALL branch protection rules: +# ./github-delete-branch-protection.ps1 -PersonalAccessToken "xxx" -GitHubOrg "myorg" -GitHubRepo "myrepo" -PatternToDelete "*" + +# Example that deletes branch protection rules that start with feature: +# ./github-delete-branch-protection.ps1 -PersonalAccessToken "xxx" -GitHubOrg "myorg" -GitHubRepo "myrepo" -PatternToDelete "feature*" + +# Set up API Header +$AuthenticationHeader = @{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$($PersonalAccessToken)")) } + +####### Script ####### + +### GRAPHQL +# Get body +$body = @{ + query = "query BranchProtectionRule { + repository(owner:`"$GitHubOrg`", name:`"$GitHubRepo`") { + branchProtectionRules(first: 100) { + nodes { + pattern, + id + matchingRefs(first: 100) { + nodes { + name + } + } + } + } + } + }" +} + +write-host "Getting policies for repo: $GitHubRepo ..." +$graphql = Invoke-RestMethod -Uri "https://api.github.com/graphql" -Method POST -Headers $AuthenticationHeader -ContentType 'application/json' -Body ($body | ConvertTo-Json) # | ConvertTo-Json -Depth 10 +write-host "" + +foreach($policy in $graphql.data.repository.branchProtectionRules.nodes) { + if($policy.pattern -like $PatternToDelete) { + write-host "Deleting branch policy: $($policy.pattern) ..." + $bodyDelete = @{ + query = "mutation { + deleteBranchProtectionRule(input:{branchProtectionRuleId: `"$($policy.id)`"}) { + clientMutationId + } + }" + } + $toDelete = Invoke-RestMethod -Uri "https://api.github.com/graphql" -Method POST -Headers $AuthenticationHeader -ContentType 'application/json' -Body ($bodyDelete | ConvertTo-Json) + + if($toDelete -like "*errors*") { + write-host "Error deleting policy: $($policy.pattern)" -f red + } + else { + write-host "Policy deleted: $($policy.pattern)" + } + } +} +``` + +## Usage + +As an example, if I wanted to clean up all of the branch protection rules on my feature branches, the script can be called like: + +```powershell +./github-delete-branch-protection.ps1 -PatternToDelete "feature*" -PersonalAccessToken "xxx" -GitHubOrg "myorg" -GitHubRepo "myrepo" +``` + +Alternatively, if I wanted to delete ALL branch protection rules, I can use a `"*"` wildcard to delete them all: + +```powershell +./github-delete-branch-protection.ps1 -PatternToDelete "*" -PersonalAccessToken "xxx" -GitHubOrg "myorg" -GitHubRepo "myrepo" +``` + +## Output + +Here's an example of an output / logs from the script: + +``` +Getting policies for repo: gh-cli-get-branches-example ... + +Deleting branch policy: test1 ... +Policy deleted: test1 +Deleting branch policy: test2 ... +Policy deleted: test2 +``` + +## Notes + +If you have more than 100 branch protection rules that you are cleaning, update the `branchProtectionRules(first: 100)` and `matchingRefs(first: 100)` diff --git a/_posts/2022-06-28-actions-runner-controller-without-cert-manager.md b/_posts/2022-06-28-actions-runner-controller-without-cert-manager.md new file mode 100644 index 0000000..215a522 --- /dev/null +++ b/_posts/2022-06-28-actions-runner-controller-without-cert-manager.md @@ -0,0 +1,186 @@ +--- +title: 'Configure actions-runner-controller without cert-manager' +author: Josh Johanning +date: 2022-06-28 16:00:00 -0500 +description: Configure actions-runner-controller without cert-manager so that you can use self-signed or self-managed certificates to scale your GitHub runners +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, Actions Runner Controller] +media_subpath: /assets/screenshots/2022-06-28-actions-runner-controller-without-cert-manager +image: + path: pods.png + width: 100% + height: 100% + alt: actions-runner-controller pods for our autoscaling GitHub runners +--- + +## Overview + +> This legacy version of Actions Runner Controller is [no longer supported by GitHub](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners-with-actions-runner-controller/about-support-for-actions-runner-controller#about-support-for-actions-runner-controller-versions). You should switch to the Autoscaling Runner Sets version of ARC. This post is left here for historical purposes only. +{: .prompt-danger } + +[Actions Runner Controller](https://github.com/actions-runner-controller/actions-runner-controller) is a great way to set up self-scaling GitHub runners in a Kubernetes cluster. This allows teams to scale up their self-hosted runners as more jobs are queued throughout the day and scale down at night when there are fewer jobs running. There is a lot of documentation to digest in the repository, but for the most part, it's relatively easy to get started with some basic scaling. The only prerequisite (other than having access to the Kubernetes cluster and administrative access to GitHub repo or organization) is [cert-manager](https://cert-manager.io/docs/). + +However, sometimes organizations have their own certificate requirements, and prefer to manage their own certificates vs. letting a tool like cert-manager manage them. This is where the [current documentation is lacking and unclear](https://github.com/actions-runner-controller/actions-runner-controller#using-without-cert-manager). Other people have asked the [same question](https://github.com/actions-runner-controller/actions-runner-controller/issues?q=is%3Aissue+in%3Atitle+without+cert-manager+), as well as my most recent customer, and we were able to figure it out so I'll document it here! + +## Configuring without cert-manager + +I'm going to be creating self-signed certificates, but you could imagine this working with certificates provided to you from the security team. If creating your own certificates, you will need `openssl` installed and callable via the command line. + +1\. Create RSA keys for CA cert and Server cert - this will output `ca.key`{: .filepath} and `server.key`{: .filepath} + +```bash +openssl genrsa -out ca.key 4096 +openssl genrsa -out server.key 4096 +``` + +2\. Create a CA configuration file (`ca.conf`{: .filepath}) - the `basicConstraints` and `keyUsage` sections are required in order for the CA to be able to sign certificates + +```config +basicConstraints = CA:TRUE +keyUsage = cRLSign, keyCertSign +[req] +distinguished_name = req_distinguished_name +prompt = no +[req_distinguished_name] +C = US +ST = SomeState +L = SomeCity +O = SomeOrg +emailAddress = your@email.com +CN = actionrunners.yourorg.com +``` +{: file='ca.conf'} + +3\. Create the CA certificate with the `ca.conf`{: .filepath} file - this will output `ca.crt`{: .filepath} + +```bash +openssl req -x509 -new -sha512 -nodes -key ./ca.key -days 7307 -out ./ca.crt -config ./ca.conf +``` +{: .nolineno} + +4\. Optionally validate that the CA certificate created successfully + +```bash +openssl x509 -noout -text -in ./ca.crt +``` +{: .nolineno} + +5\. Create your Server certificate config file - ie `server.conf`{: .filepath} + +- All 3 SANs (`alt_names`) are needed +- For the 3rd `alt_name`, the `actions-runner-system` is the namespace - if you are installing into a different namespace, replace `actions-runner-system` with the namespace you are installing to (ie: `default`) + +```text +[req] +default_bits = 4096 +prompt = no +default_md = sha256 +x509_extensions = v3_req +distinguished_name = dn + +[dn] +C = US +ST = SomeState +L = SomeCity +O = SomeOrg +emailAddress = your@email.com +CN = actionrunners.yourorg.com + +[v3_req] +subjectAltName = @alt_names + +[alt_names] +DNS.1 = webhook-service.actions-runner-system.svc +DNS.2 = webhook-service.actions-runner-system.svc.cluster.local +DNS.3 = actions-runner-controller-webhook.actions-runner-system.svc +``` +{: file='server.conf'} + +6\. Create the server Certificate Signing Request (csr) - this will outut `server.csr`{: .filepath} + +```bash +openssl req -new -key ./server.key -out ./server.csr -config ./server.conf +``` +{: .nolineno} + +7\. Create your Server certificate - this will output `server.crt`{: .filepath} + +```bash +openssl x509 -req -in ./server.csr -CA ./ca.crt -CAkey ./ca.key \ + -CAcreateserial -out ./server.crt -days 10000 \ + -extensions v3_req -extfile ./server.conf +``` +{: .nolineno} + +8\. Optionally inspect your Server cert to make sure it has the Subject Alternate Names (SANs, aka the `alt_names` from step #5) + +```bash +openssl x509 -noout -text -in ./server.crt +``` +{: .nolineno} + +9\. Base64 the CA cert and copy to clipboard (`pbcopy` is a macOS command, if running elsewhere just echo `$CA_BUNDLE` and copy) + +```bash +CA_BUNDLE=$(cat ca.crt | base64) +echo $CA_BUNDLE | pbcopy +``` + +10\. Set the `admissionWebHooks.caBundle` value in the `values.yaml`{: .filepath} file to the base64 value of the CA cert - you may have to remove the extra `{}` under `admissionWebHooks` + +```yaml +admissionWebHooks: + # {} # need to remove this + caBundle: "Ci0tL..." +``` + +11\. In the `values.yaml`{: .filepath} file, ensure `certManagerEnabled` is set to false + +```yaml +certManagerEnabled: false +``` + +12\. Create your certificate secrets using `kubectl` - both of these are needed + +```bash +kubectl create secret tls webhook-server-cert -n actions-runner-system \ + --cert=./server.crt --key=./server.key +kubectl create secret tls actions-runner-controller-serving-cert -n actions-runner-system \ + --cert=./server.crt --key=./server.key +``` + +13\. Run the `helm upgrade` command to install the controller + +```bash +helm upgrade --install --namespace actions-runner-system --create-namespace \ + --wait actions-runner-controller actions-runner-controller/actions-runner-controller \ + --values ./values.yaml +``` + +Note: Make sure to you have ran the `helm repo add` [command already](https://github.com/actions-runner-controller/actions-runner-controller#installation) + +14\. Ensure that your `actions-runner-controller` pod in the `actions-runner-system` namespace has started - if it fails, describe the pod and check the events + +15\. Deploy your runner configuration - [my example](https://gist.github.com/joshjohanning/4c4ccd3998d81552be940b07649d609a) with org runners and metric-based scaling + +```bash +kubectl apply -f runner.yaml --namespace default +``` + +16\. Ensure that your runner pods have started (describe them if not); check GitHub to see if your runner(s) show there also + +Here's me running [`k9s`](https://k9scli.io/) to visualize the pods and ensure they are running: +![Using k9s to ensure pods are running](pods.png){: .shadow } +_The controller running in the actions-runner-system namespace; the runner pods running in the default namespace_ + +Corresponding runners show up as org runners in GitHub: +![Org runners in GitHub](runners.png){: .shadow } +_Runners with the same name as the pod name show up as org runners in GitHub_ + +## Summary + +[actions-runner-controller](https://github.com/actions-runner-controller/actions-runner-controller) is great, but sometimes you need some good trial-and-error to do things off the beaten path. Luckily, we were able to figure out how to configure the controller without using [cert-manager](https://cert-manager.io/docs/) by interpreting the errors we saw when describing the failing pods. + +As an enhancement, I could see you wanting a separate certificate for the `actions-runner-controller-serving-cert` and `webhook-server-cert` in step #12 - but I'll leave further certificate optimizations to the certificate pros. + +Feel free to ask any questions or share any enhancements! diff --git a/_posts/2022-07-01-my-macos-development-environment.md b/_posts/2022-07-01-my-macos-development-environment.md new file mode 100644 index 0000000..45e0ee7 --- /dev/null +++ b/_posts/2022-07-01-my-macos-development-environment.md @@ -0,0 +1,257 @@ +--- +title: 'My macOS Development Environment: iTerm2, oh-my-zsh, and VS Code' +author: Josh Johanning +date: 2022-07-01 14:30:00 -0500 +description: Detailing out my local macOS development environment with iTerm, oh-my-zsh with the powerlevel10k theme, VS Code, and more. +categories: [macOS, Development Environment] +tags: [VS Code, Codespaces, Development Environment] +media_subpath: /assets/screenshots/2022-07-01-my-macos-development-environment +image: + path: iterm2.png + width: 100% + height: 100% + alt: iTerm2 with oh-my-zsh and the powerlevel10k theme +--- + +## Overview + +A new team member had just joined my team at GitHub and it was their first time using macOS as the primary work machine. They had asked if I had any tips on setting up your local development environment. Hint: I do! I also came from a Windows background and only first started using macOS for work in late 2019. + +I was going to link them to my [Powerlevel10k Zsh Theme in GitHub Codespaces](/posts/github-codespaces-powerlevel10k/), but then I realized: this is for setting up a development environment in _Codespaces_, not so much locally. I wrote up these instructions for my co-worker, but I thought I would re-purpose them into a blog post that I can share with others as well! + +## iTerm2, oh-my-zsh, and powerlevel10k theme setup + +1. Install iTerm2: `brew install --cask iterm2` +2. Install the [MesloLGS fonts](https://github.com/romkatv/powerlevel10k#meslo-nerd-font-patched-for-powerlevel10k) +3. Download my [iTerm profile](https://github.com/joshjohanning/dotfiles/blob/main/iterm2-profile.json) as a json file and import into iTerm + - In iTerm, go to: Preferences > Profile, you can use the `+` to import the `iterm2-profile.json`{: .filepath} profile + - I believe the only other special things that I have in the profile (other than colors) is the ability to use `Option ⌥` + `←` or `→` arrow keys to to go left / right to the end of strings, [`Option ⌥` + `Shift ⇧` + `←` or `→` arrow keys](https://stackoverflow.com/questions/30055402/how-to-select-text-in-iterm-with-shiftarrow) to highlight entire strings, and `Option ⌥` + `Delete` to delete entire strings +4. Install [oh-my-zsh](https://ohmyz.sh/#install) (run the `curl` command) +5. Install plugins like [zsh-autosuggestions](https://github.com/zsh-users/zsh-autosuggestions/blob/master/INSTALL.md#oh-my-zsh), [zsh-syntax-highlighting](https://github.com/zsh-users/zsh-syntax-highlighting/blob/master/INSTALL.md#in-your-zshrc) (basically you clone the repo and then add the plugin to the list of `plugins` in your `~/.zshrc`{: .filepath} file +6. Install [powerlevel10k zsh theme](https://github.com/romkatv/powerlevel10k#oh-my-zsh) - basically clone the repo and modify the `~/.zshrc`{: .filepath} file to update the `ZSH_THEME` +7. You will be prompted to configure powerlevel10k - but my configuration for `~/.p10k.zsh`{: .filepath} is [here](https://github.com/joshjohanning/dotfiles/blob/main/.p10k.zsh) +8. My `~/.zshrc`{: .filepath} config is [here](https://github.com/joshjohanning/dotfiles/blob/main/.zshrc) +9. Make iTerm2 the default terminal: Make iTerm default terminal (`Control ^` + `Shift ⇧` + `Command ⌘` + `\`) + +That should be all you need to make your terminal look exactly like mine 😀. + +![iTerm terminal](iterm2.png){: .shadow } +_My iTerm terminal_ + +If you're using the powerlevel10k theme, make sure to set up the [font in VS Code's terminal](#terminal-fonts) as well! + +> You can now back up your `~/.zshrc`{: .filepath} file and `~/.p10k.zsh`{: .filepath} files in a dotfiles repository similar to [mine](https://github.com/joshjohanning/dotfiles) by creating symlinks (documentation on how to do this is in my [repo](https://github.com/joshjohanning/dotfiles) also). +{: .prompt-info } + +## VS Code + +### Terminal Fonts + +To allow VS Code's terminal to look similar to the iTerm terminal, there are a few additional things we need. Add/modify these lines to your VS Code `settings.json`{: .filepath} file by opening the command palette (`Cmd ⌘`/`Ctrl` + `Shift ⇧` + `P`) and typing `> Preferences: Open Settings (JSON)`: + +```json +{ + "terminal.integrated.shell.osx": "/bin/zsh", + "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.fontFamily": "MesloLGS NF", +} +``` +{: file='settings.json'} + +Now the terminal in VS Code looks nice also! + +![VS Code terminal](vscode.png){: .shadow } +_The terminal looks good in VS Code too!_ + +> Pro-tip: Turn on [VS Code settings sync](https://code.visualstudio.com/docs/editor/settings-sync)! +{: .prompt-tip } + +### Extensions + +I'll just highlight some of my favorite extensions that I use in VS Code: + +1. [GitHub Actions](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-github-actions) - native GitHub Actions YAML syntax and Actions workflow visualization in the IDE +2. [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) - because Copilot! 🤖 +3. [GitHub Copilot Chat](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat&ssr=false) - also Copilot! 🤖 💬 +4. [Markdown All in One](https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one) - I love this because I can highlight a piece of text and paste in a link and it will automatically format the markdown for me, similar to [this feature in GitHub](https://github.blog/changelog/2022-02-02-paste-links-directly-in-markdown/) +5. [GitHub Markdown Preview](https://marketplace.visualstudio.com/items?itemName=bierner.github-markdown-preview) - a non-official GitHub extension to make the markdown preview look more like how GitHub renders markdown +6. [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker) - to help me from misspelling, and as a bonus if you're using [VS Code settings sync](https://code.visualstudio.com/docs/editor/settings-sync), you can keep a custom dictionary synced across VS Code instances / Codespaces by using the "quick fix" on aka `Cmd ⌘` + `.` on unrecognized words and "add to user settings" +7. [YAML](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) - for YAML syntax highlighting in the editor +8. [Draw.io Integration](https://marketplace.visualstudio.com/items?itemName=hediet.vscode-drawio) - for creating charts/architecture diagrams directly in VS Code +9. [GitLens](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens) - among other things, allows the ability to Git Blame view inline [like you can in GitHub](https://github.blog/2017-01-18-navigate-file-history-faster-with-improved-blame-view/) +10. [Git Graph](https://marketplace.visualstudio.com/items?itemName=mhutchie.git-graph) - another option for visualizing Git branches in VS Code +11. [Error Lens](https://marketplace.visualstudio.com/items?itemName=usernamehw.errorlens) - to tweak how errors and warnings are shown +12. [TODO Highlight](https://marketplace.visualstudio.com/items?itemName=wayou.vscode-todo-highlight) - highlights those `TODO` comments in your code +13. [GitHub Theme](https://marketplace.visualstudio.com/items?itemName=GitHub.github-vscode-theme) - make your VS Code look more like GitHub! + +### Key Bindings + +Coming from Windows, my brain is wired that `Ctrl` + `Z` is undo and `Ctrl` + `Y` is redo. In macOS, undo is `Cmd ⌘` + `Z` and redo is `⌘` + `Shift ⇧` + `Z`. I added this key binding to allow for both `Cmd ⌘` + `Shift ⇧` + `Z` AND `Cmd ⌘` + `Y` to be used for redo while editing in VS Code. + +You can modify VS Code's `keybindings.json`{: .filepath} file by opening the command palette (`Cmd ⌘`/`Ctrl` + `Shift ⇧` + `P`) and typing `> Preferences: Open Keyboard Shortcuts (JSON)`: + +```json +{ + "keybindings": [ + { + "key": "ctrl+z", + "command": "undo", + "when": "editorTextFocus" + }, + { + "key": "ctrl+shift+z", + "command": "redo", + "when": "editorTextFocus" + } + ] +} +``` +{: file='keybindings.json'} + +### Tabs, Spaces, and Paste Formatting + +When editing GitHub Actions workflows, I would get frustrated when it would add 4 spaces for a tab vs. the customary 2 that the UI editor uses by default. Additionally, it would try to format the YAML upon pasting, which would often break the YAML. To fix this, I added the following to my VS Code `settings.json`{: .filepath} file by opening the command palette (`Cmd ⌘`/`Ctrl` + `Shift ⇧` + `P`) and typing `> Preferences: Open Settings (JSON)`: + +```json +"[yaml]": { + "editor.tabSize": 2, + "editor.autoIndent": "none" +} +``` +{: file='settings.json'} + +I set this specifically for `YAML`{: .filepath} files, but you can set it for any file type by updating the header (or removing the header so that all file types use the same settings). + +### Colorized Bracket Pairs + +I used to use [Bracket Pair Colorizer 2](https://marketplace.visualstudio.com/items?itemName=CoenraadS.bracket-pair-colorizer-2), but this is now [built-in to VS Code](https://code.visualstudio.com/blogs/2021/09/29/bracket-pair-colorization) by adding this to your VS Code `settings.json`{: .filepath} file by opening the command palette (`Cmd ⌘`/`Ctrl` + `Shift ⇧` + `P`) and typing `> Preferences: Open Settings (JSON)`: + +```json +"editor.guides.bracketPairs": true +``` +{: file='settings.json'} + +## Brew + +My `Brewfile`{: .filepath} of things that I have installed with Brew is [here](https://github.com/joshjohanning/dotfiles/blob/main/Brewfile). + +You can install everything in the `Brewfile`{: .filepath} by running: + +```bash +brew bundle install --file=./Brewfile +``` +{: .nolineno} + +I was able to generate the `Brewfile`{: .filepath} by running: + +```bash +brew bundle dump --force +``` +{: .nolineno} + +> `brew bundle dump` also includes installed VS Code extensions! +{: .prompt-tip } + +## App Store Apps + +These are my must have App Store apps: + +1. [Magnet](https://apps.apple.com/us/app/magnet/id441258766?mt=12) ($) - for pinning windows to certain regions of the screen +2. [Copyclip](https://apps.apple.com/us/app/copyclip-clipboard-history/id595191960?mt=12) (free) - for clipboard management + - I like to go into preferences and remember and display 2,000 clippings and start at system startup! +3. [Get Plain Text](https://apps.apple.com/us/app/get-plain-text/id508368068?mt=12) (free) - paste without formatting + - I set my keyboard shortcut to `Option ⌥` + `Cmd ⌘` + `v` as well as launching at startup +4. [MeetingBar](https://apps.apple.com/us/app/meetingbar-for-meet-zoom-co/id1532419400?mt=12) (free) - to show upcoming meetings in the menu bar +5. [Gifski](https://apps.apple.com/us/app/gifski/id1351639930?mt=12) (free) - for creating GIFs from videos +6. [Pro Mouse](https://apps.apple.com/us/app/pro-mouse/id1505869474?mt=12) ($) - a better mouse cursor for presentations +7. [Homie](https://apps.apple.com/us/app/homie-menu-bar-app-for-homekit/id1533590432?mt=12) (free) - for controlling HomeKit devices in the menu bar +8. [Bitwarden](https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12) (free) - password management +9. [Netspot: Wifi Analyzer](https://apps.apple.com/us/app/netspot-wifi-analyzer/id514951692?mt=12) (free) - for scanning WiFi networks and seeing signal strength + +## System Settings + +- Add the sound icon to the menu bar (easily switch sound outputs) + - System Settings > Control Center > Sound - Always Show in Menu Bar + +## Keyboard Shortcuts + +### General Keyboard Shortcuts + +These are helpful, out of the box keyboard shortcuts that I often use: + +| Action | Shortcut | +|-----------------------------------------------|---------------------------------------------------| +| Hide the windows of the front app | `Cmd ⌘` + `h` | +| Minimize the front window to the dock | `Cmd ⌘` + `m` | +| Hide all but current app | `Option ⌥` + `Cmd ⌘` + `h` | +| Use the app in full screen / exit full screen | `Control ^` + `Cmd ⌘` + `f` | +| Maximize an app (not full screen) / put back | `Option ⌥` + click maximize button | +| Find again (when using Cmd ⌘ f) | `Cmd ⌘` + `g` | +| Quit app | `Cmd ⌘` + `q` | +| Force quit an app | `Option ⌥` + `Cmd ⌘` + `esc` | +| Screenshot the entire screen | `Cmd ⌘` + `Shift ⇧` + `3` | +| Screenshot a selection using a picker | `Cmd ⌘` + `Shift ⇧` + `4` | +| Screenshot or record a selection | `Cmd ⌘` + `Shift ⇧` + `5` | +| Screenshot the touch bar (RIP) | `Cmd ⌘` + `Shift ⇧` + `6` | +| Screenshot entire window | `Space bar` when in screenshot mode | +| Switch between open apps | `Cmd ⌘` + `tab` | +| Switch between multiple windows of app | `Cmd ⌘` + `` ` `` (backtick key) | +| Quit app when switching between apps | When in the `Cmd ⌘` + `tab` interface, press `q` | +| See all apps | `F3` (may need `fn` + `F3`) | +| See desktop | `Cmd ⌘` + `F3` (may need `fn` + `Cmd ⌘` + `F3`) | +| Switch between desktops/maximized apps | `Control ^` + `←` or `→` arrow keys | +| Spotlight Search | `Cmd ⌘` + `Space bar` | +| Re-order menu bar icons | Hold `Cmd ⌘` and drag | +| Refresh page (in browser) | `Cmd ⌘` + `r` | +| View history (in browser) | `Cmd ⌘` + `y` | + +### Finder Keyboard Shortcuts + +| Action | Shortcut | +|-------------------------|-------------------------------------------------| +| Delete a file | `Cmd ⌘` + `delete` | +| Rename a file | `return` | +| Show/hide hidden files | `Cmd ⌘` + `Shift ⇧` + `.` | +| Cut (move) file | Copy normally, then `Option ⌥` + `Cmd ⌘` + `v` | +| Open a file / folder | `Cmd ⌘` + `o` or `Cmd ⌘` + `↓` arrow key | +| Enter folder | `Cmd ⌘` + `↓` arrow key | +| Leave folder | `Cmd ⌘` + `↑` arrow key | +| Rename file/folder | `return` | + +### Text Editing Keyboard Shortcuts + +| Action | Shortcut | +|-----------------------------------|---------------------------------------------------| +| Delete whole line | `Cmd ⌘` + `delete` | +| Delete just the last word | `Option ⌥` + `delete` | +| Go to the beginning of the line | `Control ^` + `a` | +| Go to the end of the line | `Control ^` + `e` | +| Highlight just one word | `Shift ⇧` + `Option ⌥` + `←` or `→` arrow keys | +| Highlight the entire line | `Shift ⇧` + `Cmd ⌘` + `←` or `→` arrow keys | +| Jump to bottom of document | `Cmd ⌘` + `↓` arrow key | +| Jump to top of document | `Cmd ⌘` + `↑` arrow key | +| Highlight text vertically | `Shift ⇧` + `Option ⌥` + select with mouse | + +## Other Tips + +- Open a new Finder window from the current directory in Terminal: `open .` +- Update the modified time of a file: `touch -mt202303261924 ./file-to-update.xyz` +- Terminate all instances of a process: `killall Finder` (case sensitive) +- Open file in application: drag the file over the app (e.g. VS Code) in the dock to open +- Output the URL and title of each tab in all open Chrome windows: + ```bash + osascript -e{'set o to""','tell app"google chrome"','repeat with t in tabs of windows','set o to o&url of t&" "&title of t&linefeed',end,end}|sed \$d + ``` + {: .nolineno} + +## Troubleshooting + +> **Question**: I'm seeing special characters that aren't rendering in my terminal? +> +> **Answer**: Make sure you install the [MesloLGS fonts](https://github.com/romkatv/powerlevel10k#meslo-nerd-font-patched-for-powerlevel10k) and configure it to be used in iTerm and VS Code. Powerlevel10k uses custom glyphs from the font to render the terminal correctly. + +## Summary + +Before writing this post, I had most of this in OneNote of what to do when I get a new Mac. Most things are automated, but some like the App Store Apps I install, are not. I plan on sharing this with folks who ask how to get started quickly on a new Mac! + +Let me know anything I missed or improvements I can make here, or tips for anyone else coming over from the Windows world 🙏 diff --git a/_posts/2022-07-02-github-dependabot-for-actions.md b/_posts/2022-07-02-github-dependabot-for-actions.md new file mode 100644 index 0000000..8c7d22f --- /dev/null +++ b/_posts/2022-07-02-github-dependabot-for-actions.md @@ -0,0 +1,122 @@ +--- +title: 'Configure GitHub Dependabot to Keep Actions Up to Date' +author: Josh Johanning +date: 2022-07-02 08:00:00 -0500 +description: Using Dependabot to keep Actions in GitHub Actions Workflows up to date, including how this works for custom private/internal actions within an organization +categories: [GitHub, Dependabot] +tags: [GitHub, Dependabot, Pull Requests, GitHub Actions] +media_subpath: /assets/screenshots/2022-07-02-github-dependabot-for-actions +image: + path: dependabot-pr-post-image.png + width: 100% + height: 100% + alt: Dependabot created pull requests for both marketplace and private / custom actions +--- + +## Overview + +You probably know that Dependabot can be used to update your packages, such as NPM or NuGet, but did you also know you can use it to keep Actions up to date in your GitHub Actions Workflow? + +What about for custom Actions that you have create in your organization, did you know you can use Dependabot to keep those up to date as well? + +I will show you how to do this both for Actions in the public marketplace and custom actions you have created in your organization internally. + +## Marketplace Actions + +Configuring Dependabot with marketplace actions is pretty easy. We're using the [Dependabot Version Updates](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuring-dependabot-version-updates) functionality, so we have to create our [`dependabot.yml`{: .filepath}](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file) file manually. There are 3 ways to do this: + +1. Under the repository Settings page > Code security and analysis > Dependabot version updates, you can click the Configure button to prepopulate the `dependabot.yml`{: .filepath} file. +2. Under the repository Insights page > Dependency Graph > Dependabot > Create Config File +3. Create your own file in the `.github/dependabot.yml`{: .filepath} directory. + +Whichever one you pick, you will still have to configure the `dependabot.yml`{: .filepath} file with which [package ecosystems](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem) you want it to pick up. + +For GitHub Actions in the marketplace, it would look like this: + +```yml +version: 2 +updates: + + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + # Workflow files stored in the default location of `.github/workflows` + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 10 +``` +{: file='.github/dependabot.yml'} + +Note that even though your workflows are in the `.github/workflows`{: .filepath} directory, Dependabot still expects the `directory` on line 8 to be set to `"/"` ([documented here](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#directory)). + +I also like to set `open-pull-requests-limit` explicitly, otherwise the [default maximum number of pull requests](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#open-pull-requests-limit) that will be created per package ecosystem defined is `5`. + +## Custom Actions in Organization + +So far, this is pretty well documented. But what is a little harder to figure out is how to use this for custom actions in private/internal repositories within an organization. There are two different ways to do this, and I will talk about both. + +The first doesn't require any different configuration than the above. However, when you use a custom action, you will see an error in Dependabot: + +![Error in Dependabot using custom action](dependabot-error.png){: .shadow } +_Dependabot throws an error and requests you to grant access_ + +You would have to grant access to _every_ custom action repository in your organization - which seems untenable. + +You can [proactively add the private action repositories](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/managing-security-and-analysis-settings-for-your-organization#allowing-dependabot-to-access-private-dependencies) via Organization Settings > Code security and analysis > Grant Dependabot access to private repositories, but again, this seems less than ideal, especially since I don't think there's an API or GraphQL method of updating this. + +![Granting Dependabot access to private repos](dependabot-private-repos.png){: .shadow } +_Granting Dependabot access to private repos in organization settings_ + +The second way to do this is to use a Dependabot secret (GitHub PAT) and a `git` repository as a [private registry](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#git). + +Here's the `dependabot.yml`{: .filepath} file: + +{% raw %} +```yml +version: 2 +updates: + + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + # Workflow files stored in the default location of `.github/workflows` + directory: "/" + schedule: + interval: "daily" + registries: + - github +registries: + github: + type: git + url: https://github.com + username: x-access-token # username doesn't matter + password: ${{ secrets.GHEC_TOKEN }} # dependabot secret +``` +{: file='.github/dependabot.yml'} + +You'll notice the `password: ${{ secrets.GHEC_TOKEN }}` on line 17. We need to create a [Dependabot Secret](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/managing-encrypted-secrets-for-dependabot) using a [GitHub Personal Access Token (PAT)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token). I know that managing a PAT can be annoying and potentially insecure, but on the upside, Dependabot Secrets can _only_ be accessed via Dependabot, and the Dependabot implementation is essentially a black box to us. They can't be accessed maliciously / inappropriately through GitHub Actions workflow runs. + +What I recommend is: + +1. Creating a machine user or service account that only has read-access to the repositories in the organization - note that this will consume a license +2. Log into that account and [create a PAT](https://github.com/settings/personal-access-tokens/new) that doesn't expire with only the `repositories` scope selected +3. Create an [organization secret for Dependabot](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/managing-encrypted-secrets-for-dependabot#adding-an-organization-secret-for-dependabot) - this way all the repositories in the organization will be able to access the Dependabot secret + +Notes: +- In theory you could [create the PAT](https://github.com/settings/personal-access-tokens/new) under anyone's account since no one can dump the PAT from a GitHub Action workflow and it would be fine, but I think it's better to have it under a machine user or service account so that Dependabot will still work if / when that original person is no longer with the company +- Another reason to use a machine user or service account is that you can't currently create a PAT with a read only repo scope - it's all or nothing +- And before you ask: you unfortunately can't use a GitHub App here, at least not with the native Dependabot implementation + +{% endraw %} + +Once you configure the `dependabot.yml`{: .filepath} and Dependabot secret as discussed above, the next time Dependabot runs, it will create pull requests for you for both marketplace AND private / custom actions. + +![Dependabot created pull requests for both marketplace and private / custom actions](dependabot-pr.png){: .shadow } +_Dependabot created pull requests for both marketplace and private / custom actions_ + +## What About Reusable Workflows? + +You're in luck! Check out this [post](/posts/dependabot-reusable-workflows/) of mine for the details. + +## Summary + +Keeping marketplace actions up to date is one thing, but keeping your custom actions might be just as important! With the magic of Dependabot, you can keep your custom actions up to date without having to manually check for updates. diff --git a/_posts/2022-08-03-lap-around-github-advanced-security.md b/_posts/2022-08-03-lap-around-github-advanced-security.md new file mode 100644 index 0000000..639f09e --- /dev/null +++ b/_posts/2022-08-03-lap-around-github-advanced-security.md @@ -0,0 +1,24 @@ +--- +title: 'A Lap Around GitHub Advanced Security (30m Video)' +author: Josh Johanning +date: 2022-08-03 21:00:00 -0500 +description: A video describing the features of GitHub Advanced Security (GHAS), features, and some tips and tricks for configuring and interpreting the results along the way. +categories: [GitHub, Advanced Security] +tags: [GitHub, Dependabot, GitHub Actions, GitHub Advanced Security, Branch Protection Rules, CodeQL, Policy Enforcement, Pull Requests] +media_subpath: /assets/screenshots/2022-08-03-lap-around-github-advanced-security +image: + path: video-preview.png + width: 66% + height: 66% + alt: Video preview for the 'A Lap Around GitHub Advanced Security' video +--- + +## Overview + +I realized that there wasn't any content of me speaking on the internet, just blogging, so I thought I would at least create one! This is a video I created to explain the features of GitHub Advanced Security (GHAS), features, and some tips and tricks for configuring and interpreting the results along the way. + +I admittedly didn't put a ton of polish around the video, but I hope it's useful as a primer if you are new to GitHub Advanced Security, or even if you're not new, perhaps you'll learn a few new things along the way. + +Enjoy! + +{% include embed/youtube.html id='iovXhIru5Bs' %} diff --git a/_posts/2022-08-11-github-script-to-add-users-to-teams.md b/_posts/2022-08-11-github-script-to-add-users-to-teams.md new file mode 100644 index 0000000..3641633 --- /dev/null +++ b/_posts/2022-08-11-github-script-to-add-users-to-teams.md @@ -0,0 +1,45 @@ +--- +title: 'GitHub: Script to Mass Add Users to a Team' +author: Josh Johanning +date: 2022-08-11 16:00:00 -0500 +description: Add users to a GitHub org team programmatically from a CSV file +categories: [GitHub, Scripts] +tags: [GitHub, Scripts, gh cli] +media_subpath: /assets/screenshots/2022-08-11-github-script-to-add-users-to-teams +image: + path: github-team.png + width: 100% + height: 100% + alt: Adding a user to a team in GitHub +--- + +## Overview + +If you've ever had to add several users to a team in a GitHub organization, you know it can be a pain as it's one user at a time and multiple clicks per add. This script aims to simplify that by adding users to a GitHub org team programmatically from a CSV file. + +## The Script + +This script is in my [github-misc-scripts](https://github.com/joshjohanning/github-misc-scripts) repo: + +- [`add-users-to-team-from-list.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/add-users-to-team-from-list.sh) + +## Using the Script + +Prerequisite: You need to make sure to have the [`gh cli`](https://cli.github.com/) installed and authorized (`gh auth login`). + +1. Create a `users.csv`{: .filepath} with the list of users to add to the team, one per line, and leave a trailing empty line/whitespace at the end of the file. The file should look like: + ``` + user1 + user2 + + ``` + {: file='users.csv'} +2. From there, it's pretty simple - run the script, passing in the `users.csv`{: .filepath} file, org name, and team name: + ```bash + ./add-users-to-team-from-list.sh users.csv + ``` + {: .nolineno} + +## Summary + +Hopefully this saves you some time in the UI when adding multiple users to a team! diff --git a/_posts/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action.md b/_posts/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action.md new file mode 100644 index 0000000..aefb8a4 --- /dev/null +++ b/_posts/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action.md @@ -0,0 +1,91 @@ +--- +title: 'Azure DevOps Commit Message Validator and PR Linker GitHub Action' +author: Josh Johanning +date: 2022-08-17 13:00:00 -0500 +description: Enforce that each commit in a pull request has AB# in the commit message and link all of the work items to the pull request +categories: [GitHub, Actions] +tags: [Azure DevOps, Work Items, GitHub, GitHub Actions, Pull Requests, Branch Protection Rules] +media_subpath: /assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action +image: + path: blocking-pr-post-image.png + width: 100% + height: 100% + alt: Blocking a PR from merging because it's missing an AB# in the commit message +--- + +## Overview + +I was with a client recently that was using GitHub for source control and GitHub Advanced Security, and Azure DevOps for Boards and Pipelines. [Integrating GitHub with Azure DevOps](https://docs.microsoft.com/en-us/azure/devops/boards/github/link-to-from-github?view=azure-devops) is relatively simple for linking commits and pull requests, but there were a few pieces that we wanted to improve on. One was making sure / enforcing in the pull request that each commit contains an Azure Boards work item link with `AB#123` in the commit message. We also found that commits that contained work item links weren't automatically linked to the pull request. The pull request needs to contain `AB#123` in the pull request title or body in order for the link to be automatically created. + +Because of these limitations, I built an [action](https://github.com/joshjohanning/azdo_commit_message_validator) to be ran in a pull request to make sure that all commits have a `AB#123` link in the commit message, as well as link all corresponding work items to the pull request. + + +## Using the Action + +The [action](https://github.com/joshjohanning/azdo_commit_message_validator) loops through each commit and: + +1. makes sure it has `AB#123` in the commit message +2. if yes, add a GitHub Pull Request link to the work item in Azure DevOps + +### Prerequisites + +1. Create a repository secret titled `AZURE_DEVOPS_PAT` - it needs to be a full PAT +2. Pass the Azure DevOps organization to the azure-devops-organization input parameter (line no. 14 below) + +### YML + +This should only be triggered via pull requests. + +{% raw %} +```yml +name: pr-commit-message-enforcer-and-linker + +on: + pull_request: + branches: [ "main" ] + +jobs: + pr-commit-message-enforcer-and-linker: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Azure DevOps Commit Validator and Pull Request Linker + uses: joshjohanning/azdo_commit_message_validator@v1 + with: + azure-devops-organization: myorg # The name of the Azure DevOps organization + azure-devops-token: ${{ secrets.AZURE_DEVOPS_PAT }} # "Azure DevOps Personal Access Token (needs to be a full PAT) + fail-if-missing-workitem-commit-link: true # Fail the action if a commit in the pull request is missing AB# in the commit message + link-commits-to-pull-request: true # Link the work items found in commits to the pull request +``` +{: file='.github/workflows/pr-commit-message-enforcer-and-linker.yml'} + +{% endraw %} + +### Branch Protection Policy + +After you create the workflow, you can add this as a status check to the branch protection policy on your default branch. If you aren't seeing the `pr-commit-message-enforcer-and-linker` job name, you might have to create a pull request that triggers the job first and then add the branch protection policy. +![Branch protection policy](branch-protection-policy.png){: .shadow } +_Configuring the status check in the branch protection policy_ + +Once added, if commit message(s) don't contain an `AB#123` link, the pull request will be blocked from merging. +![Status checks failing on pull request](checks-failing-on-pr.png){: .shadow } +_The status checks on the pull request are failing because of missing work item links in the commit message(s)_ + +## Screenshots + +If a commit in the pull request is missing AB# in the commit message, the action will fail: +![Blocking the pull request because it's missing work item links](blocking-pr.png){: .shadow } +_Blocking the pull request because it's missing work item links_ + +The action will link all work items found in commits to the pull request: +![Linking the work items to the pull request](linking-workitem-to-pr.png){: .shadow } +_Linking the work items to the pull request_ + +The pull request showing along with the commit on the work item in Azure DevOps: +![Pull request](pr-link.png){: .shadow } +_Pull request link on a work item in Azure DevOps_ + +## Summary + +The gist is that it makes sure that all commits in the pull request have an AB# link in the commit message, and that all work items found in the commits are linked to the pull request. I'm working with an undocumented API that I describe a bit more in the [README of the repository](https://github.com/joshjohanning/azdo_commit_message_validator/#how-this-works) if you're interested. Test it out - feedback's always welcome! diff --git a/_posts/2022-09-30-migrating-repos-to-github.md b/_posts/2022-09-30-migrating-repos-to-github.md new file mode 100644 index 0000000..0003153 --- /dev/null +++ b/_posts/2022-09-30-migrating-repos-to-github.md @@ -0,0 +1,129 @@ +--- +title: 'Migrating Repos to GitHub' +author: Josh Johanning +date: 2022-09-30 12:00:00 -0500 +description: The different options available for migrating repos to GitHub or any other Git provider +categories: [GitHub, Migrations] +tags: [Azure DevOps, GitHub, TFVC, SVN, Git, Migrations] +media_subpath: /assets/screenshots/2022-09-30-migrating-repos-to-github +image: + path: import-repo-github.gif + width: 66% + height: 66% + alt: Importing a repository to GitHub using the GitHub Importer +--- + +## Overview + +There are several options for migrating repos to GitHub, depending on what Source Control Management (SCM) tool you are coming from, and what you want to migrate. Just the Git repo with all of its history? Because it's Git to Git, it's [easy](#option-2-git-clone-mirror) Only care about the latest changes and want to start fresh? Perhaps even easier, pull or get latest, create a new Git repo, add in the changes, and push. If it's not Git, you must determine if you want to use a specialized tool to migrate or start fresh. This post will cover all of the different options, when you should use them, and any known gotchas. + +Often, simply migrating your entire Git repository (with history) is sufficient and the most recommended approach. If you need to persist the metadata of your issues and pull requests comments for audit purposes, one option is to leave around the old system in a read-only state until it can be retired. + +## Note on Commit Authors + +The way that [GitHub attributes commits to an author](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address#about-commit-email-addresses) is via the [email address in the `git config`](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address#setting-your-commit-email-address-in-git). In GitHub, you can add multiple email addresses, and if you want to see your GitHub avatar show up next to your commits, make sure to [add your email address](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address#setting-your-commit-email-address-on-github) to your GitHub account. + +Look at the example below. The first edit I made in GitHub.com, so it shows up as me. The second edit I made on my local machine, and since my `git config` was set up without using an email address linked to my GitHub account, the commit does not show up as from me: + +![Git commits, email addresses, and how GitHub renders the commit author](git-config.png){: .shadow } +_Since the email in my Git config isn't added to my GitHub account, the commit doesn't link to my profile_ + +To summarize, use `git config --global --edit` to view/update your local Git config and make sure the email address listed here is also [added in GitHub](https://github.com/settings/emails). + +## Option 1: Using GitHub Importer + +I often forget about this one, and if your repo is on a SAAS service (like Azure DevOps, BitBucket Cloud, etc.), this is going to be the way to go. The [GitHub Importer](https://docs.github.com/en/get-started/importing-your-projects-to-github/importing-source-code-to-github/about-github-importer) works with Git, SVN, and Mercurial. This tool will import the contents of the repository, including history. For repos that require authentication, the wizard walks you through providing that information (typically a personal access token or API key). I've personally used this for migrating Azure DevOps Git repos as well as BitBucket Cloud Git repos to GitHub. + +The downside is that it won't work for those who have self-hosted instances of your source control system (such as Azure DevOps Server or BitBucket Server) since these are likely not internet-facing. It also won't migrate any pull request or issue metadata information. + +If using SVN or Mercurial, you will have the ability to [update commit author attribution](https://docs.github.com/en/get-started/importing-your-projects-to-github/importing-source-code-to-github/updating-commit-author-attribution-with-github-importer) during the process. For Git, see the note above on [commit authors](#note-on-commit-authors). + +> Note: The [docs](https://docs.github.com/en/get-started/importing-your-projects-to-github/importing-source-code-to-github/about-github-importer) say that this tool supports TFVC, but I've never been able to get it to work, so if you're looking for TFVC, [keep reading](#option-6-tfvc-to-git)! +> +> > No source repositories were detected at \/_versionControl. Please check the URL and try again. +{: .prompt-info } + +## Option 2: Git Clone Mirror + +This is usually what I do when migrating Git repos to GitHub, since it's fast, efficient, and scriptable 😀. This migrates the entire repository, including all history, branches, and tags. The steps are as follows: + +```bash +# Pre-requisite: Create repo in GitHub and grab the URL + +# migrate the repository +git clone --mirror +cd +git push --mirror +cd .. +rm -rf +``` + +The [`--mirror`](https://www.git-scm.com/docs/git-clone#Documentation/git-clone.txt---mirror) clones a bare copy of the repository that contains all refs (branches, tags, notes, etc.). With the bare copy of the repository, the files in the directory aren't human readable, that's why we clean up with the `rm -rf` at the end. Run a `git clone ` to get a local copy of the repository and begin working with the repository in GitHub. + +This of course works for transferring Git repos between any Git SCM. + +A nice script that can do mass migrations is available [here](https://gist.github.com/dbirks/ed249df1912ec11214327a06e24d816c). + +## Option 3: Transfer a Repo in GitHub + +If your repository is already in GitHub, and you just need to move it to another user or organization, did you know you can simply [transfer the repo](https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository#transferring-a-repository-owned-by-your-personal-account)? + +## Option 4: Get Latest and Start Fresh + +Starting fresh might be best for repositories with very old history, and/or repositories with a lot of binaries committed. Here's how this process might look: + +1. Create a new EMPTY repo in GitHub - don't initialize with a README or .gitignore +2. Get your latest changes in existing SCM; e.g.: `git pull` or `tf get` to get latest changes +3. Create a new folder and copy in the files from your existing repo - make sure to to NOT copy over the `.git`{: .filepath} or `$tf`{: .filepath} folder +4. Initialize a new Git repo: `git init` +5. **Important**: Add in a [gitignore](https://git-scm.com/docs/gitignore) file - for .NET developers, you can run `dotnet new gitignore`, alternatively see [github/gitignore](https://github.com/github/gitignore) repo for other templates +6. Delete any binaries or other files you don't want committed to the new repo, and/or modify gitignore as needed +7. Add the origin: `git remote add origin ` +8. Push: `git push -u origin main` + +Entirely scripted out, it would look something like this: + +```bash +cd old-repo +# grab latest changes +git pull +# copy files to new folder and cd into it +mkdir ./../new-repo && cp -r ./* ./../new-repo && cd ./../new-repo +# initialize new git repo +git init +# add in and populate gitignore - either yourself or use `dotnet new gitignore` +touch .gitignore +# add in files, commit, and make sure we're using the main branch +git add . +git commit -m "Initial commit" +git branch -M main +# add remote and push +git remote add origin +git push -u origin main +``` + +## Option 5: SVN to Git + +1. See my [blog post](https://josh-ops.com/posts/migrate-svn-to-git/)! +2. Otherwise, if your SVN repo is internet-accessible, see: [GitHub Importer](#option-1-using-github-importer) + +## Option 6: TFVC to Git + +1. Perhaps the simplest is to use the [Azure Repos Git repo importer](https://learn.microsoft.com/en-us/azure/devops/repos/git/import-from-TFVC?view=azure-devops) to convert from TFVC to Git, and then migrate that repo to GitHub using [Option 1](#option-1-using-github-importer) or [Option 2](#option-2-git-clone-mirror) above + - If self-hosted, requires TFS 2017.2 or later + - Note that it can only migrate 180 days of history and only supports 1 branch +2. For more advanced migrations, use the [Git-TFS tool](https://github.com/git-tfs/git-tfs) + - See this page for more information on the [`git-tfs clone`](https://github.com/git-tfs/git-tfs/blob/master/doc/commands/clone.md) command + - There are various blog posts [here](https://gist.github.com/AAugustine/268f7eed2043de24526b9254a0881579), [here](https://medium.com/sestek/how-to-migrate-projects-from-tfs-to-git-ff23d6b0c910), and [here](https://gitstack.com/how-to-migrate-from-tfs-to-git/) if you need additional guidance + +See [Microsoft's documentation](https://learn.microsoft.com/en-us/devops/develop/git/migrate-from-tfvc-to-git) for more information on this topic. + +## Option 7: Other Third-Party Tools + +[Search GitHub](https://github.com/search?q=git+migrate+repo+&type=repositories) and see what else is out there! + +## Summary + +There is no shortage of options for migrating repositories, and while Git is certainly the easiest, there are still options for SVN, Mercurial, and TFVC out there. + +I hope this post helps you find the right tool for your migration needs! 🥳 diff --git a/_posts/2022-10-06-github-script-to-delete-repos.md b/_posts/2022-10-06-github-script-to-delete-repos.md new file mode 100644 index 0000000..c0e6d45 --- /dev/null +++ b/_posts/2022-10-06-github-script-to-delete-repos.md @@ -0,0 +1,63 @@ +--- +title: 'GitHub: Script to Mass Delete Repos' +author: Josh Johanning +date: 2022-10-06 11:00:00 -0500 +description: Delete GitHub repositories programmatically from a CSV file +categories: [GitHub, Scripts] +tags: [GitHub, Scripts, gh cli] +media_subpath: /assets/screenshots/2022-10-06-github-script-to-delete-repos +image: + path: delete-repo.png + width: 100% + height: 100% + alt: Deleting a repo in GitHub +--- + +## Overview + +If you've ever had to delete several repositories in GitHub, you know it can be a pain as you have to copy/paste the name of each repo in the verification prompt. This script aims to simplify that by providing a list of repositories that you want to delete in a CSV, and then looping through each repo and deleting it with the [`gh cli`](https://cli.github.com/). + +## The Scripts + +These scripts are in my [github-misc-scripts](https://github.com/joshjohanning/github-misc-scripts) repo: + +- [`generate-repositories-list.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/generate-repositories-list.sh) +- [`delete-repositories-from-list.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/delete-repositories-from-list.sh) + +## Using the Scripts + +### Prerequisites + +- You need to make sure to have the [`gh cli`](https://cli.github.com/) installed and authorized (`gh auth login`). +- Add the `delete_repo` scope: + ```bash + gh auth refresh -h github.com -s delete_repo + ``` + {: .nolineno} + +### Usage + +1. Prepare a list of repositories that you want to delete and place in a CSV file, one per line, with the last line empty. + - You can use the [`generate-repositories-list.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/generate-repositories-list.sh) script to generate a list of repos in a GitHub org, and then modify accordingly: + ```bash + ./generate-repositories-list.sh joshjohanning-org > repos.csv + ``` + {: .nolineno} + - Or, create your own CSV file with the list of repos you want to delete, one per line, and leave a trailing empty line/whitespace at the end of the file. The file should look like: + ```sh + org/repo1 + org/repo2 + + ``` + {: file='repos.csv'} +2. From there, it's pretty simple - run the script, passing in the `repos.csv`{: .filepath} file: + ```bash + ./delete-repositories-from-list.sh repos.csv + ``` + {: .nolineno} + +## Summary + +If you accidentally delete a repository that you didn't mean to, the good news is that you have [90 days to recover it](https://docs.github.com/en/repositories/creating-and-managing-repositories/restoring-a-deleted-repository) in most cases (with some caveats for forks). + +Hopefully this script saves you some time in the UI copy/pasting repo names when you want to do some spring cleaning! diff --git a/_posts/2022-10-12-using-github-checks-api.md b/_posts/2022-10-12-using-github-checks-api.md new file mode 100644 index 0000000..218c2ff --- /dev/null +++ b/_posts/2022-10-12-using-github-checks-api.md @@ -0,0 +1,266 @@ +--- +title: 'Using the GitHub Checks API to Link Workflow Statuses in a PR' +author: Josh Johanning +date: 2022-10-12 20:30:00 -0500 +description: Using the GitHub Checks API to report the status of another workflow back to the pull request for gating purposes +categories: [GitHub, Actions] +tags: [GitHub, Scripts, gh cli] +mermaid: true +media_subpath: /assets/screenshots/2022-10-12-using-github-checks-api +image: + path: status-check.gif + width: 100% + height: 100% + alt: Example of Status Checks in a Pull Request +--- + +## Overview + +I was talking to a colleague [@colindembovsky](https://colinsalmcorner.com/) the other day about how to report back to the PR the status of a GitHub Action workflow that was triggered programmatically via the `workflow_dispatch` event. The use case was that a workflow would be triggered when a [certain label was added to the PR](https://github.com/colindembovsky/mindaro/blob/main/.github/workflows/bikes-label-trigger.yml#L15:L16) that runs the deployment workflow via the `workflow_dispatch` event. This worked well, but the problem was that there was no way to report back to the PR the status of the deployment workflow. We could ultimately see on the PR if the deployment workflow was successful or not, but it would be nice to have a [status check](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks) on the PR that would show the status of the deployment workflow like we do for actions specifically triggered by the PR. + +To summarize, this is the flow and challenge statement: + +1. PR is created +2. CI job runs +3. A label is added to the PR, e.g.: `deploy to demo` +4. A [workflow](https://github.com/colindembovsky/mindaro/blob/main/.github/workflows/bikes-label-trigger.yml#L15:L16) that is just listening for the label event is triggered +5. The label update workflow calls the [deployment workflow](https://github.com/colindembovsky/mindaro/blob/main/.github/workflows/deploy-component.yml#L5) via the `workflow_dispatch` event +6. The label job shows up as a status check in the PR +7. The deployment job runs +8. We want to post back to the PR the status of the deployment job, but because the job wasn't created by the PR, it doesn't show up as a status check on the PR + +The status checks are shown on the image below. Notice how the job to label the PR shows up here, but not the job the subsequent job queued programmatically via the `workflow_dispatch` event: + +![Status checks on a pull request](status-checks-missing-deployment-on-pr.png){: .shadow } +_Only the status checks associated with the pull request show up here_ + +Basically, what we want to happen is this: + +```mermaid +graph LR + A[PR Created] --> B[CI Job Runs] + B --> C[Label Added] + C --> D[Workflow Dispatched] + D --> E[Deployment Runs] + E --> F[Deployment Status Posted Back to PR] +``` + +## Checks API + +But first, let's take a step back and explain what the Checks API is. We can use the [Checks API](https://docs.github.com/en/rest/checks), specifically the [create a check run API](https://docs.github.com/en/rest/checks/runs#create-a-check-run), to create a check run that will show up as a [status check](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks) on the PR. + +From the [docs](https://web.archive.org/web/20221230233914/https://docs.github.com/en/rest/guides/getting-started-with-the-checks-api), the Check Runs API is described as: + +> The Check Runs API enables you to build GitHub Apps that run powerful checks against code changes in a repository. You can create apps that perform continuous integration, code linting, or code scanning services and provide detailed feedback on commits. +> +> A check run is an individual test that is part of a check suite. Each run includes a status and conclusion. +> +> GitHub automatically adds new check runs to the correct check suite based on the check run's repository and SHA. +> +> ![Check runs](check_runs.png){: width="600" }{: .shadow } +_Only the status checks associated with the pull request show up here_ + +There's a critical keyword there: `SHA`. A check run is created on a particular SHA, and this is how we are going to link our second job back to the original PR: by using the SHA of the commit that triggered the workflow. + +In the documentation, you will see Check Suites and Check Runs. A Check Suite is just a collection of Check Runs. By default, GitHub creates a check suite automatically when code is pushed to the repository. We want to create a Check Run to show up in the Check Suite that shows up as Status Checks when a PR is created. + +The Checks API was introduced in May 2018. If you are interested in more of the history and other use cases of the API, follow the [link](https://github.blog/2018-05-07-introducing-checks-api/). + +## Implementation + +Alright, here's the good part of the article! Let me explain what type of actions we need, and some options for implementing: + +1. An action to create the check: + - I'm going to use [LouisBrunner/checks-action](https://github.com/LouisBrunner/checks-action) to create the check for us + - We could alternatively write our own [API call](https://docs.github.com/en/rest/checks/runs#create-a-check-run) or use the 'Create a check run' [octokit](https://octokit.github.io/rest.js/v19#checks-create) method for this, but the action allows us to [import a markdown file as the summary of the check](https://github.com/LouisBrunner/checks-action/pull/24), which is quite nice +2. An action to queue the deployment workflow with the `workflow_dispatch` event. A few more options here: + - The [benc-uk/workflow-dispatch](https://github.com/benc-uk/workflow-dispatch) action + - The [colindembovsky/deployment-lifecycle-actions/create-deployment-from-label](https://github.com/colindembovsky/deployment-lifecycle-actions) - this action creates a deployment when a label is added to the PR + - The [actions/github-script](https://github.com/actions/github-script) to call the 'Create a workflow dispatch event' [octokit](https://octokit.github.io/rest.js/v19#actions-create-workflow-dispatch) method + - Since it's a pretty simple call, I am going to use this option. This is what this would look like as an action: + + {% raw %} + ```yml + - name: Trigger Workflow + uses: actions/github-script@v6 + with: + script: | + github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'new-workflow.yml', + ref: '${{ github.head_ref }}', + inputs: { + "workflow-input-1": "value-1", + "workflow-input-2": "value-2", + } + }) + ``` + {% endraw %} + +We also need to decide if we are going to use a GitHub App or use provided the `GITHUB_TOKEN` to create the status check. If we use the `GITHUB_TOKEN`, it will show up as created from the actions[bot] like the other checks show up as. If we use a GitHub App, it will show up as with the GitHub App's avatar image. The `GITHUB_TOKEN` is easier, but I found out via an [issue](https://github.com/LouisBrunner/checks-action/issues/18#issuecomment-1027122151) in the [LouisBrunner/checks-action](https://github.com/LouisBrunner/checks-action) repo, that if we want to provide a custom `details_url`, we need to use a GitHub App. The `details_url` can be used to provide a custom link, such as a link to a third-party dashboard where results are uploaded. Note that unlike most things in GitHub, we CANNOT use a PAT for this. + +This is how each look: + +1. Using the `github_token`: + ![Status check created with `GITHUB_TOKEN`, as shown in a pull request](github-token-check.png){: .shadow } + _Status check created with github_token, as shown in a Pull Request_ + ![Status check created with `GITHUB_TOKEN` - the URL links backs to the action workflow run](github-token-check-url.png){: .shadow } + _Status check created with github_token, after clicking on details - note the at the bottom, it links to the action workflow run_ +2. Using a GitHub App: + ![Status check created with a GitHub App, as shown in a pull request](github-app-check.png){: .shadow } + _Status check created with github_token, as shown in a Pull Request_ + ![Status check created with a GitHub App - we can customize the URL](github-app-check-url.png){: .shadow } + _Status check created with github_token, after clicking on details - note the at the bottom, we can provide a custom link_ + +If you would like more information on how to create a GitHub App or what that even is, see my post: [Demystifying GitHub Apps: Using GitHub Apps to Replace Service Accounts](/posts/github-apps/). + +You'll notice in the example above, I'm attaching my generated code coverage markdown report. See related post: [Code Coverage Reports with GitHub Actions](/posts/github-code-coverage/). + +## The YML + +Now, once you have decided on using a `github_token` or GitHub App, you can move on to creating the workflows. Since I'm using a GitHub App, I'm using an additional [action](https://github.com/tibdex/github-app-token) to obtain the app's installation access token. + +First, we have the `.github/workflows/create-deployment.yml`{: .filepath} workflow. Note where we initialize the check run with a status of `in_progress` on line 29, trigger the `deployment.yml`{: .filepath} workflow on line 35, and if this job fails, conclude the check run with the job's failure status on line 53. We could have hardcoded the conclusion using the [options in the API](https://docs.github.com/en/rest/checks/runs#create-a-check-run), but I choose to stick with the job's status for consistency (with the condition, would be `failure`). This failure is here to ensure that if for some reason the job fails before it can even call the second workflow, we want to report a failure. + +{% raw %} +```yml +name: create deployment + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + create-deployment: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: tibdex/github-app-token@v1 + id: get_installation_token + with: + app_id: 170284 + private_key: ${{ secrets.PRIVATE_KEY }} + + - uses: LouisBrunner/checks-action@v1.3.1 + id: check + with: + sha: ${{ github.sha }} + token: ${{ steps.get_installation_token.outputs.token }} + # token: ${{ github.token }} + name: Second Job + status: in_progress + + - name: Trigger Workflow + uses: actions/github-script@v6 + with: + script: | + github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'deployment.yml', + ref: '${{ github.head_ref }}', + inputs: { + "workflow-input-1": "value-1", + "workflow-input-2": "value-2", + } + }) + + - uses: LouisBrunner/checks-action@v1.3.1 + if: failure() + with: + sha: ${{ github.sha }} + token: ${{ steps.get_installation_token.outputs.token }} + # token: ${{ github.token }} + name: Second Job + conclusion: ${{ job.status }} + details_url: https://josh-ops.com/posts/github-code-coverage/ + action_url: https://josh-ops.com/posts/github-code-coverage/ + output: | + {"summary":""} + output_text_description_file: code-coverage-results.md +``` +{: file='.github/workflows/create-deployment.yml'} + +Next, we have the `.github/workflows/create-deployment.yml`{: .filepath} workflow which is triggered via the `workflow_dispatch` event from the workflow above. Note where we always conclude the check run with the deployment job's status on line 39. If this job finishes successfully, the status will be `success`. If this job fails, the status will be `failure`. + +```yml +name: deployment + +on: + workflow_dispatch: + inputs: + workflow-input-1: + description: 'workflow-input-1' + required: true + default: '' + workflow-input-2: + description: 'workflow-input-2' + required: true + default: '' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: tibdex/github-app-token@v1 + id: get_installation_token + with: + app_id: 170284 + private_key: ${{ secrets.PRIVATE_KEY }} + + - run: | + echo "Hello, world!" + echo "workflow-input-1 is ${{ github.event.inputs.workflow-input-1 }}" + echo "workflow-input-2 is ${{ github.event.inputs.workflow-input-2 }}" + + - uses: LouisBrunner/checks-action@v1.3.1 + if: always() + with: + sha: ${{ github.sha }} + token: ${{ steps.get_installation_token.outputs.token }} + # token: ${{ github.token }} + name: Second Job + conclusion: ${{ job.status }} + details_url: https://josh-ops.com/posts/github-code-coverage/ + action_url: https://josh-ops.com/posts/github-code-coverage/ + output: | + {"summary":""} + output_text_description_file: code-coverage-results.md +``` +{: file='.github/workflows/deployment.yml'} +{% endraw %} + +When updating and/or concluding the check run, we just have to make sure we use the same `name` as the initial check run. + +The resulting status check can be seen in the sample pull request [here](https://github.com/joshjohanning-org/PrimeService-unit-testing-using-dotnet-test/pull/11). + +## Advanced Checks + +We've just scratched the surface with how powerful Checks can be! The Checks API allows you to report rich details about each check run, including statuses, images, summaries, annotations, and requested actions. There is a [really great example in the GitHub docs](https://docs.github.com/en/enterprise-server@3.4/developers/apps/guides/creating-ci-tests-with-the-checks-api) demonstrating all of these features in an example app. + +### Annotations + +Using the [LouisBrunner/checks-action](https://github.com/LouisBrunner/checks-action#annotations), we can create an annotation that links to a particular line in the code. An example of this is how the CodeQL action reports security vulnerabilities. See the screenshot below: + +![Example using Checks to create a line annotation](annotation-example.png){: .shadow } +_Example using Checks to create a line annotation_ + +### Requested Actions + +You can also have your check run implement certain fixes with the click of a button using [requested actions](https://web.archive.org/web/20221230233914/https://docs.github.com/en/rest/guides/getting-started-with-the-checks-api#check-runs-and-requested-actions). An example the [docs](https://web.archive.org/web/20221230233914/https://docs.github.com/en/rest/guides/getting-started-with-the-checks-api#check-runs-and-requested-actions) gives is how a code linting app could automatically fix detected syntax errors: + +![Example using Checks to create a line annotation](github_apps_checks_fix_this_button.png){: .shadow } +_Example using Checks to create a line annotation_ + +## Summary + +I've been aware of the Checks API, but I haven't explored much of it yet. When researching a solution for the initial problem, I found that there wasn't a ton of resources out there, so hence this blog post. The Checks API is super powerful, and allows for a lot of creativity and flexibility with how you want to stage your workflows. I hope this post helps you in your GitHub Actions journey! + +Happy check-ing! ✅ ❌ 🤓 diff --git a/_posts/2022-11-23-github-packages-migrate-nuget-packages.md b/_posts/2022-11-23-github-packages-migrate-nuget-packages.md new file mode 100644 index 0000000..4ee56bb --- /dev/null +++ b/_posts/2022-11-23-github-packages-migrate-nuget-packages.md @@ -0,0 +1,128 @@ +--- +title: 'GitHub Packages: Migrate NuGet Packages Between GitHub Instances' +author: Josh Johanning +date: 2022-11-23 13:30:00 -0500 +description: Migrating NuGet packages stored in GitHub Packages from one instance to another +categories: [GitHub, Packages] +tags: [GitHub, Scripts, GitHub Packages, gh cli, NuGet, Migrations] +media_subpath: /assets/screenshots/2022-11-23-github-packages-migrate-nuget-packages +image: + path: github-packages.png + width: 100% + height: 100% + alt: NuGet packages in GitHub Packages +--- + +## Overview + +I recently had a customer ask me how they could migrate their NuGet packages from one GitHub instance to another (e.g.: from GitHub Enterprise Server to GitHub Enterprise Cloud). I wasn't aware of any tooling that did this, so I decided to write my own. + +> See my other NuGet package migration posts: +> +> - [Quickly Migrate NuGet Packages to a New Feed](/posts/nuget-pusher-script/) +> - [Migrate NuGet Packages to GitHub Packages](/posts/github-packages-migrate-nuget-packages-to-github-packages/) +{: .prompt-info } +> See my other GitHub Package --> GitHub Package migration posts: +> +> - [Migrate npm Packages Between GitHub Instances](/posts/github-packages-migrate-npm-packages/) +> - [Migrate Maven Packages Between GitHub Instances](/posts/github-packages-migrate-maven-packages/) +> - [Migrate Docker containers Between GitHub Instances](/posts/github-packages-migrate-docker-containers/) +{: .prompt-info } + +## The script + +The repo and docs can be found here: + +- **[https://github.com/joshjohanning/github-packages-migrate-nuget-packages-between-github-instances](https://github.com/joshjohanning/github-packages-migrate-nuget-packages-between-github-instances)** + +I decided to store the script in a separate GitHub repo than my [github-misc-scripts](/posts/github-misc-scripts/) repo to better facilitate any feedback/suggestions/improvements I might get - feel free to submit a PR if you can improve things 🚀! + +## Running the script + +### Prerequisites + +1. [`gh cli`](https://cli.github.com) installed +2. Set the source GitHub PAT env var: `export GH_SOURCE_PAT=ghp_abc` (must have at least `read:packages`, `read:org` scope) +3. Set the target GitHub PAT env var: `export GH_TARGET_PAT=ghp_xyz` (must have at least `write:packages`, `read:org` scope) + +Notes: + +- This script installs [gpr](https://github.com/jcansdale/gpr) locally to the `./temp/tools`{: .filepath} directory +- This script assumes that the target org's repo name is the same as the source +- If the repo doesn't exist, the package will still import but won't be mapped to a repo + +### Usage + +You can call the script via: + +```bash +./migrate-nuget-packages-between-orgs.sh \ + + \ + +``` + +### Example + +An example of this in practice: + +```bash +export GH_SOURCE_PAT=ghp_abc +export GH_TARGET_PAT=ghp_xyz + +./migrate-nuget-packages-between-orgs.sh \ + joshjohanning-org-packages \ + github.com \ + joshjohanning-org-packages-migrated +``` + +## Notes + +- The script assumes that the target org's repo has the same name as the source - it's not required, but the package won't be mapped to a repo if the target repo doesn't exist +- The script uses [`gpr`](https://github.com/jcansdale/gpr) to re-push the packages to the target org + + I initially tried writing this with [`dotnet nuget push`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-nuget-push), but that doesn't seem to work since the package's `` element would still be referencing the original repository. See error: + + ``` + dotnet nuget push \ + -s github \ + -k ghp_pat \ + NUnit3.DotNetNew.Template_1.7.1.nupkg + + Pushing NUnit3.DotNetNew.Template_1.7.1.nupkg to 'https://nuget.pkg.github.com/joshjohanning-org-packages-migrated'... + PUT https://nuget.pkg.github.com/joshjohanning-org-packages-migrated/ + warn : Source owner 'joshjohanning-org-packages-migrated' does not match repo owner 'joshjohanning-org-packages' in repository element. + BadRequest https://nuget.pkg.github.com/joshjohanning-org-packages-migrated/ 180ms + error: Response status code does not indicate success: 400 (Bad Request). + ``` + {: file='\'dotnet nuget push\' error'} + + + [`gpr`](https://github.com/jcansdale/gpr) works because it rewrites the `` element in the `.nuspec`{: .filepath} file in the `.nupkg`{: .filepath} before pushing + + Update Dec 2022: Now that NuGet Packages has supports granular permissions and organization sharing ([GitHub's roadmap item](https://github.com/github/roadmap/issues/589)), [`dotnet nuget push`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-nuget-push) should work - but using [`gpr`](https://github.com/jcansdale/gpr) for mapping convenience + - [`gpr`](https://github.com/jcansdale/gpr) still might be preferred since you would have to tie the NuGet package to the repository manually post-migration + - If attempting to use [`dotnet nuget push`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-nuget-push), you will have to add the feed first using this command: + ```bash + dotnet nuget add source \ + --username my-github-username \ + --password "ghp_pat" \ + --store-password-in-clear-text \ + --name github \ + "https://nuget.pkg.github.com/OWNER/index.json" + ``` + {: .nolineno} +- Also, in the script, I had to delete `_rels/.rels`{: .filepath} and `[Content_Types].xml`{: .filepath} because there was somehow two copies of each file in the package and it causes gpr to fail when extracting/zipping the package to re-push +- To clean up the working directory when done, run this one-liner: + ```bash + rm -rf ./temp + ``` + {: .nolineno} + +## Improvement Ideas + +* [x] Add a source folder input instead of relying on current directory (just using `./temp`{: .filepath}) +* [ ] Map between repositories where the target repo is named differently than the source repo (likely this isn't needed since if repo doesn't exist, packages will still be pushed, the package just won't be linked to a repository) +* [x] Dynamically determine out where [`gpr`](https://github.com/jcansdale/gpr) is installed instead of passing in a parameter (right now we are passing the `gpr` path in as a parameter explicitly because sometimes `gpr` is aliased to `git pull --rebase`) (installing `gpr` locally to the `./temp/tools`{: .filepath} directory) +* [x] Update script because of GitHub Packages GraphQL [deprecation](https://github.blog/changelog/2022-08-18-deprecation-notice-graphql-for-packages/) + +## Summary + +Drop a comment here or an issue or PR on the [repo](https://github.com/joshjohanning/github-packages-migrate-nuget-packages-between-github-instances) if you have any feedback or suggestions! Happy packaging! 📦 diff --git a/_posts/2022-12-02-github-packages-migrate-nuget-packages-to-github-packages.md b/_posts/2022-12-02-github-packages-migrate-nuget-packages-to-github-packages.md new file mode 100644 index 0000000..95c29db --- /dev/null +++ b/_posts/2022-12-02-github-packages-migrate-nuget-packages-to-github-packages.md @@ -0,0 +1,166 @@ +--- +title: 'GitHub Packages: Migrate NuGet Packages to GitHub Packages' +author: Josh Johanning +date: 2022-12-02 12:30:00 -0500 +description: Migrating NuGet packages stored in GitHub Packages from one instance to another +categories: [GitHub, Packages] +tags: [GitHub, Scripts, GitHub Packages, gh cli, NuGet, Migrations] +mermaid: true +media_subpath: /assets/screenshots/2022-11-23-github-packages-migrate-nuget-packages +image: + path: github-packages.png + width: 100% + height: 100% + alt: NuGet packages in GitHub Packages +--- + +## Overview + +To complete my NuGet Package migration series, I wanted to demonstrate how one could migrate NuGet packages to GitHub Package. The other system we are migrating from (whether it be Azure Artifacts, Artifactory, etc.) doesn't so much matter, as long as we are able to download/access the `.nupkg`{: .filepath} files on a system so that we can re-push to GitHub Packages. + +> See my other NuGet package migration posts: +> - [Quickly Migrate NuGet Packages to a New Feed](/posts/nuget-pusher-script/) +> - [Migrate NuGet Packages Between GitHub Instances](/posts/github-packages-migrate-nuget-packages/) +{: .prompt-info } + +## The script + +The repo and docs can be found here: +- **[https://github.com/joshjohanning/github-packages-migrate-nuget-packages-to-github-packages](https://github.com/joshjohanning/github-packages-migrate-nuget-packages-to-github-packages)** + +I decided to store the script in a separate GitHub repo than my [github-misc-scripts](/posts/github-misc-scripts/) repo to better facilitate any feedback/suggestions/improvements I might get - feel free to submit a PR if you can improve things 🚀! + +## Update the mappings file + +Your `csv`{: .filepath} file should look something like this: + +``` +Package,Target GitHub Repo +./mypkg.11.0.1.nupkg,my-org/my-repo +./mypkg.11.0.2.nupkg,my-org/my-repo + +``` +{: file='packages.csv'} + +> Leave a trailing space at the end of the `csv`{: .filepath} file. +{: .prompt-info } + +## Running the script + +There are two scripts that need to be ran: + +1. Generate the list of packages to migrate in the current directory and creating a mappings `csv`{: .filepath} file + - We then need to fill out the GitHub repository mapping for each package in the form of `owner/repo` +1. Migrate the packages to GitHub Packages + +### Prerequisites + +But first, the prequisites: + +1. [`gpr`](https://github.com/jcansdale/gpr) installed: + ```bash + dotnet tool install gpr -g + ``` + {: .nolineno} +4. Can use this one-liner to find the absolute path for [`gpr`](https://github.com/jcansdale/gpr) for the `` parameter: + ```bash + find / -wholename "*tools/gpr" 2> /dev/null + ``` + {: .nolineno} +3. `` must have `write:packages` scope + +We are passing [`gpr`](https://github.com/jcansdale/gpr) in as a parameter explicitly because sometimes [`gpr`](https://github.com/jcansdale/gpr) is aliased to `git pull --rebase` and that's not what we want here. + +### Generate the Mappings File + +This finds all `.nupkg`{: .filepath} files in the current directory and generates a mappings `csv`{: .filepath} file. + +```bash +./generate-nuget-package-mappings.sh \ + . \ + > +``` + +> Use this one-liner to copy all `.nupkg`{: .filepath} files to a directory before `./generate-nuget-package-mappings.sh`{: .filepath}: +> ```bash +> find / -name "*.nupkg" -exec cp "{}" ./ \; +> ``` +> {: .nolineno} +{: .prompt-tip } + +Afterwards, you need to edit the `csv`{: .filepath} file to add the target GitHub repo reference, in the form of `owner/repo`. + +> Leave a trailing space at the end of the `csv`{: .filepath} file. +{: .prompt-info } + +### Migrate the Packages + +This pushes the packages to the mapped GitHub repo: + +```bash +./migrate-nuget-packages-to-github.sh \ + \ + \ + +``` + +### Complete Example + +An example of this in practice: + +```bash +# 1. generate mappings file +./generate-nuget-package-mappings.sh \ + . \ + > packages.csv + +# 2. edit the mappings file to add the GitHub repo in the form of `owner/repo` + +# 3. push packages +./migrate-nuget-packages-between-orgs.sh \ + packages.csv \ + ghp_xyz \ + /home/codespace/.dotnet/tools/gpr +``` + +## Notes + +- The script uses [`gpr`](https://github.com/jcansdale/gpr) to re-push the packages to the target org + + I initially tried writing this with [`dotnet nuget push`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-nuget-push), but that doesn't seem to work since the package's `` element would still be referencing the original repository. See error: + + ``` + dotnet nuget push \ + -s github \ + -k ghp_pat \ + NUnit3.DotNetNew.Template_1.7.1.nupkg + + Pushing NUnit3.DotNetNew.Template_1.7.1.nupkg to 'https://nuget.pkg.github.com/joshjohanning-org-packages-migrated'... + PUT https://nuget.pkg.github.com/joshjohanning-org-packages-migrated/ + warn : Source owner 'joshjohanning-org-packages-migrated' does not match repo owner 'joshjohanning-org-packages' in repository element. + BadRequest https://nuget.pkg.github.com/joshjohanning-org-packages-migrated/ 180ms + error: Response status code does not indicate success: 400 (Bad Request). + ``` + {: file='\'dotnet nuget push\' error'} + + + [`gpr`](https://github.com/jcansdale/gpr) works because it rewrites the `` element in the `.nuspec`{: .filepath} file in the `.nupkg`{: .filepath} before pushing + + There is an item on [GitHub's roadmap](https://github.com/github/roadmap/issues/589) to support pushing packages directly to an organization; this should allow [`dotnet nuget push`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-nuget-push) to work instead of [`gpr`](https://github.com/jcansdale/gpr) + - [`gpr`](https://github.com/jcansdale/gpr) still might be preferred since you would have to tie the NuGet package to the repository manually post-migration + - For [`dotnet nuget push`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-nuget-push) to work, you will have to add the feed first using this command: + ```bash + dotnet nuget add source \ + --username my-github-username \ + --password "ghp_pat" \ + --store-password-in-clear-text \ + --name github \ + "https://nuget.pkg.github.com/OWNER/index.json" + ``` + {: .nolineno} + +## Improvement Ideas + +* [ ] Add a source folder input instead of relying on current directory for `./generate-nuget-package-mappings.sh`{: .filepath} +* [ ] Dynamically determine out where [`gpr`](https://github.com/jcansdale/gpr) is installed instead of passing in a parameter (right now we are passing the [`gpr`](https://github.com/jcansdale/gpr) path in as a parameter explicitly because sometimes [`gpr`](https://github.com/jcansdale/gpr) is aliased to `git pull --rebase`) + +## Summary + +Drop a comment here or an issue or PR on the [repo](https://github.com/joshjohanning/github-packages-migrate-nuget-packages-to-github-packages) if you have any feedback or suggestions! Happy packaging! 📦 diff --git a/_posts/2023-02-15-github-download-latest-release.md b/_posts/2023-02-15-github-download-latest-release.md new file mode 100644 index 0000000..49833e6 --- /dev/null +++ b/_posts/2023-02-15-github-download-latest-release.md @@ -0,0 +1,60 @@ +--- +title: 'Programmatically Download Latest Release from GitHub Repo' +author: Josh Johanning +date: 2023-02-15 11:30:00 -0600 +description: Programmatically download the latest release from a GitHub Repo without having to hardcode the version or use separate API calls +categories: [GitHub, Scripts] +tags: [GitHub, Scripts, Releases] +media_subpath: /assets/screenshots/2023-02-15-github-download-latest-release +image: + path: release.png + width: 100% + height: 100% + alt: The Latest Release in a GitHub Repo +--- + +## Overview + +I recently needed to download the latest release from a GitHub repo but didn't want to hardcode the version number or use separate API calls to get the latest release. I found a few [one-liner solutions online](https://gist.github.com/steinwaywhw/a4cd19cda655b8249d908261a62687f8), but most of these made two separate calls: +1. an [API call to get the latest release](https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release) +2. then another to download that release asset + +I swore there was a way to get this in one call, but it took a little bit of digging for me to find it. I'm posting this here for posterity and future searchers 😀. + +## The script + +Well, it's not much of a script, more of a command, but here it is for both `wget` and `curl`. The only thing you need to know is the filename of the asset you want to download. In this case, it's `tfsec-linux-amd64`{: .filepath} . If the maintainers chose to add in version numbers in the filename, like `tfsec-linux-amd64-v1.28.1`{: .filepath} , you would need to use an [alternative one-liner](https://gist.github.com/steinwaywhw/a4cd19cda655b8249d908261a62687f8) to make that second API call to get the version number. + +How to download the latest version of a release asset: + +### wget + +```sh +wget https://github.com/aquasecurity/tfsec/releases/latest/download/tfsec-linux-amd64 +``` +{: .nolineno} + +### curl + +```sh +curl -LO https://github.com/aquasecurity/tfsec/releases/latest/download/tfsec-linux-amd64 +``` +{: .nolineno} + +> The `-O` (case sensitive) saves the file as the same name specified in the URL, and `-L` follows redirects. +{: .prompt-tip } + +## Download a Specific Version + +I'm posting this here in case you have the _exact opposite_ problem and need to download a specific version of a release asset. + +If you need to download a specific version of a release, you can use: + +```sh +wget https://github.com/aquasecurity/tfsec/releases/download/v1.28.1/tfsec-linux-amd64 +``` +{: .nolineno} + +## Summary + +To make your CI jobs _repeatable_, it makes more sense to have a hardcoded version of a release asset. Just make sure to update the version every now and then 😁. If you just need to download the latest version of a release asset, then the above solution will be perfect! diff --git a/_posts/2023-02-28-security-alerts.md b/_posts/2023-02-28-security-alerts.md new file mode 100644 index 0000000..9636396 --- /dev/null +++ b/_posts/2023-02-28-security-alerts.md @@ -0,0 +1,81 @@ +--- +title: 'Tips for Handling Dependabot, CodeQL, and Secret Scanning Alerts' +author: Josh Johanning +date: 2023-02-28 15:30:00 -0600 +description: My musings on handling security alerts in GitHub +categories: [GitHub, Advanced Security] +tags: [GitHub, GitHub Advanced Security, Dependabot, CodeQL, Secret Scanning] +media_subpath: /assets/screenshots/2023-02-28-security-alerts +image: + path: security-overview-light.png + width: 100% + height: 100% + alt: Security Overview for an Organization +--- + +## Overview + +I recently had the opportunity to work with a large organization to help them manage their security alerts. Often, enabling security tooling is easy, it's what comes next (how do we handle alerts, who fixes them, when are they fixed, etc.). For posterity, this post is a summary of my thoughts on how to handle security alerts in GitHub. + +## My Notes + +- It might seem obvious, but organizations should **focus on the critical and high alerts first** + - Teams can get overwhelmed by the sheer number of alerts that Dependabot finds, but should instead be focusing on the **critical/high alert count** +- Leverage **Pull requests gates** + - For **Dependencies**: Introduce **gates during pull requests** to at least not allow people to introduce *new* vulnerable dependencies (ie: use the [Dependency Review Action](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement)) + - I would **advise against blocking all PRs from being merged if ANY** high/critical Dependabot alerts are present. This would affect PRs that didn't touch dependencies as well + - Certainly, in some high-regulated environments, this could have the desired effect of **forcing** teams to fix their security issues, but in nearly all cases, **I would advise against this** + - While you can't set up a **[Required Workflow](https://docs.github.com/en/enterprise-cloud@latest/actions/using-workflows/required-workflows) for CodeQL**, you can use this for the **[Dependency Review action](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement)/status check for Dependabot** ([sample here](https://gist.github.com/joshjohanning/0d3c49431ee8e7e3a30a306f6017604a)) for enforcing this across an organization + - For **Code Scanning**: Same idea, introduce gates to **prevent people from merging new code that contains a potential vulnerability**. + - This can be done by adding the **CodeQL status check to the [branch protection rule](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/triaging-code-scanning-alerts-in-pull-requests#code-scanning-results-check)**. For best coverage, add the `CodeQL` status check as well as the `Analyze` status check(s) (ie: `Analyze (java)`) +- Some organizations introduce an **SLA** for Dependabot Alerts that are found + - Example: If a **critical Dependabot alert is found, you have 10 days** to fix it. High = 30, Medium = 60, Low = 90 (or similar) + - Typically, a **grace period** is given when turning on security settings to allow teams to burn down the backlog of alerts + - One could potentially use **[webhook event](https://docs.github.com/webhooks-and-events/webhooks/webhook-events-and-payloads#dependabot_alert) to trigger when alerts** are found and post them to a Slack or Teams channel or even as Issues on their repository! + - Ideally, **you wouldn't need to rely on the SLA for Code Scanning results** after initial onboarding since vulnerabilities should be caught and fixed during pull requests with **gates** 😄. There are **more likely to be Dependabot Alerts that are found out of band than CodeQL alerts** +- Some organizations introduce an **“alert cap”** similar to a **“bug cap”** - if we get more than 10 Dependabot alerts for example, we have to burn them down +- Ultimately, organizations **need to have procedural practices in place** (culture) to make security a concern so that people don’t “ignore” alerts and instead work to fix them + - If you're asking "**How can I snooze a Dependabot alert**", your team's approach is **wrong**. It's okay to get alerts, new vulnerabilities are discovered in packages every day, but you should be working to fix them +- Great **testing** is effective at helping you resolve Dependabot alerts + - If a Dependabot Security Alert PR is created, if your **build job and unit tests pass**, then you can be reasonably confident that the **PR is safe to merge** + - There isn't a great way to **distinguish between a Dependabot Security Update PR vs. a [Dependabot Version Update](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/about-dependabot-version-updates) PR** (non-security related), but you can use something like **labels** defined in your `dependabot.yml`{: .filepath} file to help you distinguish between the two +- Some teams ask if they can ignore **development-scoped dependencies (such as devDependencies)** + - This was added as a feature to [**Dependabot in June 2022**](https://github.blog/changelog/2022-06-23-dependabot-alerts-filter-alerts-by-the-scope-of-the-dependency-runtime-and-development/) + - While it is true your end users won't be affected by a vulnerability in a development dependency, your developers very well may be, and your developers certainly have access to privileged information that make them a **prime attack vector** +- Other **Dependabot notes** + - Upon merging the PR with the package version fix, the **Dependabot Alert will automatically be closed** (**same with Code Scanning alerts**, once the code is fixed, the alert is automatically closed) + - Not all Dependabot Alerts get created as Security Update pull requests **([there’s a limit of 10](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/troubleshooting-dependabot-errors#dependabot-cannot-open-any-more-pull-requests))**, but can be slightly less than that due to various factors + - Sometimes dependencies have a **vulnerability disclosed with no new version to update to** - in those situations you have to evaluate the risk and decide if you want to continue using that dependency or not + - Some ecosystems, like Python's `pip`, can show you if you are **[referencing a vulnerable code path in a dependency](https://github.blog/2022-04-14-dependabot-alerts-now-surface-if-code-is-calling-vulnerability/)** +- Using **[tspascoal/dependabot-alerts-helper](https://github.com/tspascoal/dependabot-alerts-helper) scripts** to export Dependabot alerts to a CSV file + - This can be used for further **analysis / grouping / management** of alerts, especially if you are responsible for a lot of repositories + - The **[Security Overview](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-overview/about-the-security-overview)** is great, but if you only care about specific repositories but have access to a lot of repositories, it can be a little noisy + - This tool also supports **merging Dependabot Security Update PRs in bulk**. The idea, in theory, is that if you used the same old version of a package throughout your organization, and are able to verify it’s a non-breaking change, then you could mass merge those open Dependabot pull requests to resolve those alerts + - This analysis and testing could be useful **if a single package was causing a high percentage of alerts** across a team/organization + - Maybe you can’t do this throughout the organization, but a single team could use some of the tools to at least make changes in **their x number of repos that they own** + - Also, who doesn't like pulling up Excel for some pretty tables/charts? 📊 +- Additional **useful apps** + - **[advanced-security/probot-security-alerts](https://github.com/advanced-security/probot-security-alerts)** - A Probot app to ensure that people are not closing security alerts without actually fixing them / require them to have the proper permissions to do so + - **[advanced-security/ghas-reviewer-app](https://github.com/advanced-security/ghas-reviewer-app)** - Similar to the above + - **[github/safe-settings](https://github.com/github/safe-settings)** - This can be useful to ensure repositories have a pull request review requirement as well as requiring the [Dependency Review](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement) status check + - **[NickLiffen/ghas-enablement](https://github.com/NickLiffen/ghas-enablement)** - Useful to push CodeQL workflows as well as enabling Security features to a set of repositories + - **[advanced-security/generate-sbom-action](https://github.com/advanced-security/generate-sbom-action)** - A GitHub Action to generate a Software Bill of Materials (SBOM) for your repository + - **[KittyChiu/probot-secret-remediation](https://github.com/KittyChiu/probot-secret-remediation/)** - A Probot app to automatically create issues when a secret scanning push protection is bypassed + - **[github/ghas-jira-integration](https://github.com/github/ghas-jira-integration)** - A GitHub Action to create Jira issues from GitHub Advanced Security alerts + - **[advanced-security/policy-as-code](https://github.com/advanced-security/policy-as-code)** - A GitHub Action to enforce policies on your repository based on risk threshold +- Integrate GHAS with other **[Security Information and Events Management (SIEM) tools](https://github.blog/2022-10-13-introducing-github-advanced-security-siem-integrations-for-security-professionals)** + - Such as **[Splunk's dashboard](https://github.com/splunk/github_app_for_splunk#integration-overview-dashboard)** + - Or **[Microsoft Sentinel](https://github.blog/2022-10-13-introducing-github-advanced-security-siem-integrations-for-security-professionals/#microsoft-sentinel)** +- What to do when you find **Secret Scanning** results + - Turn on **[push protections](https://docs.github.com/en/enterprise-cloud@latest/code-security/secret-scanning/protecting-pushes-with-secret-scanning)**! This won't block all secrets, but it will block secret types with a high confidence score to minimize disruptive false positives + - It is easier/more secure to **rotate secrets** than to **clean the repo history** with something like [BFG to remove the commit](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository) + - You still might want to clean the repo history for reasons, but regardless, **you should still rotate the secret**! And note in the README when the history was re-written for audit purposes. + - For additional coverage, create **[custom secret scanning patterns](https://docs.github.com/en/enterprise-cloud@latest/code-security/secret-scanning/defining-custom-patterns-for-secret-scanning)** based on your use cases. These can also be created with push protections enabled. Here's a [repo](https://github.com/advanced-security/secret-scanning-custom-patterns) for some predefined patterns! + +## Further Reading + +- Check out some of [@colindembovsky](https://github.com/colindembovsky/)'s posts, such as [Shift Left - How far is too far?](https://colinsalmcorner.com/shift-left-how-far-is-too-far/), [Fine Tuning CodeQL Scans using Query Filters](https://colinsalmcorner.com/fine-tuning-codeql-scans/), [GHAS Will Win the AppSec Wars](https://colinsalmcorner.com/ghas-will-win-the-appsec-wars/), and [Mission Control - and what it means for DevSecOps](https://colinsalmcorner.com/mission-control/) +- Check out [@kenmuse](https://github.com/kenmuse)'s [Security Theater](https://www.kenmuse.com/blog/security-theater/) post - just because a different vendor says they are cover every compliance rule, doesn't mean they really do +- Check out [@nickliffen](https://github.com/nickliffen)'s [Why Advanced Security?](https://nickliffen.dev/articles/why-advanced-security.html) post - "there is more to a security tool than the number of results found!" +- Check out this post from the GitHub Blog, [5 tips for prioritizing Dependabot alerts](https://github.blog/2022-09-19-5-tips-for-prioritizing-dependabot-alerts/), for additional ideas with Dependabot alerts +- Check out this page from the GitHub docs, [Adopting GitHub Advanced Security at scale](https://docs.github.com/en/enterprise-cloud@latest/code-security/adopting-github-advanced-security-at-scale), for ideas on using a phased approach to rollout GitHub Advanced Security across your organization diff --git a/_posts/2023-03-13-deprecated-github-actions-commands.md b/_posts/2023-03-13-deprecated-github-actions-commands.md new file mode 100644 index 0000000..87a283f --- /dev/null +++ b/_posts/2023-03-13-deprecated-github-actions-commands.md @@ -0,0 +1,104 @@ +--- +title: 'Finding deprecated set-output and save-state commands in GitHub Actions' +author: Josh Johanning +date: 2023-03-13 3:00:00 -0500 +description: A bash script to find usage of deprecated set-output and save-state commands as well as finding deprecated Node.js 12 actions in GitHub Actions workflows +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, gh cli, Scripts] +media_subpath: /assets/screenshots/2023-03-13-deprecated-github-actions-commands +image: + path: deprecated-workflow-command.png + width: 100% + height: 100% + alt: Deprecated workflow command in GitHub Actions +--- + +## Overview + +You might have been noticing some of these warnings in your GitHub Actions workflows: + +> Warning: The `set-output` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: [https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/](https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/) +{: .prompt-warning } + +~~GitHub is disabling the `set-output` and `save-state` commands in GitHub Actions on May 31, 2023, so it's crunch time in ensuring your workflows are updated to use the new command syntax.~~ The [changelog post](https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/) has been updated to note that telemetry shows that there is still high usage of the deprecated workflow commands and they won't be disabled just yet. + +For actions that you are authoring, the fix is relatively simply. + +Old: + +```yml +- name: Save state + run: echo "::save-state name={name}::{value}" + +- name: Set output + run: echo "::set-output name={name}::{value}" +``` + +New: + +```yml +- name: Save state + run: echo "{name}={value}" >> $GITHUB_STATE + +- name: Set output + run: echo "{name}={value}" >> $GITHUB_OUTPUT +``` + +You could simply perform a code search and update instances of `set-output` and `save-state` to the new syntax. However, this wouldn't work for actions that you are consuming from the marketplace. For example, if you were using `actions/stale@v5.2.0` in your workflows, you would see this warning in your workflow run logs. + +Thanks to [@teddyteh](https://github.com/teddyteh) in this [PR](https://github.com/joshjohanning/github-actions-log-warning-checker/pull/6), this script now also searches for deprecated [Node.js 12 actions](https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/). The fix is relatively simply here too, in the `action.yml`{: .filepath} file, use `'node16'` instead of `'node12'`: + +```yml +runs: + using: 'node16' + main: 'main.js' +``` +{: file='action.yml'} + +## Finding Deprecation Warning Command Usage + +I created a [bash solution](https://github.com/joshjohanning/github-actions-log-warning-checker) to audit a list of repositories for deprecated workflow commands. It checks through recent workflow runs (configurable) to see if there are any `set-output`, `save-state`, or `Node.js 12` deprecation warnings in the workflow run logs. The outputs are stored to a specified CSV file and denotes if the deprecation message was found in the most recent workflow run or not. The reason why I'm searching through multiple workflow runs is that there is a possibility that certain actions aren't run on every workflow run, such as a PR build, so I wanted to ensure proper coverage. + +### Usage + +Repository: [joshjohanning/github-actions-log-warning-checker](https://github.com/joshjohanning/github-actions-log-warning-checker) + +1. Run `gh auth login` to authenticate with GitHub CLI +2. Run `./generate-repos.sh > repos.csv` + - Modify list as needed + - Or create a list of repos in a csv file, ``, 1 per line, with a trailing empty line at the end of the file +3. Run: `./github-actions-log-warning-checker.sh repos.csv output.csv` + +### Example Output + +``` +repo,workflow_name,workflow_url,finding,found_in_latest_workflow_run +joshjohanning-org/actions-linter-testing,CI,https://github.com/joshjohanning-org/actions-linter-testing/blob/main/.github/workflows/blank.yml,Workflow command,no +joshjohanning-org/actions-linter-testing,new-workflow,https://github.com/joshjohanning-org/actions-linter-testing/blob/main/.github/workflows/new-file.yml,Workflow command,yes +joshjohanning-org/actions-linter-testing,node12,https://github.com/joshjohanning-org/actions-linter-testing/blob/main/.github/workflows/node12.yml,Node.js 12 action,yes +``` +{: file='output.csv'} + +### Sample repos.csv file to use for testing + +You can use this `repos.csv`{: .filepath} file for testing. It has a finding for a result for a deprecated Node.js 12 action as well as a deprecated workflow command. + +``` +joshjohanning-org/actions-linter-testing +joshjohanning-org/actions-linter-testing-clean + +``` +{: file='repos.csv'} + +### To do + +- [x] [Find deprecated Node.js 12 actions](https://github.com/joshjohanning/github-actions-log-warning-checker/issues/2) (thanks to [@teddyteh](https://github.com/teddyteh) in this [PR](https://github.com/joshjohanning/github-actions-log-warning-checker/pull/6)) +- [ ] [Use annotations to be able to return workflow run log line links](https://github.com/joshjohanning/github-actions-log-warning-checker/issues/3) (for easier discoverability of which action is using the deprecated command(s)) + +## Summary + +As an Enterprise/Organization owner, this tool can be useful to find repositories that are using deprecated workflow commands. You can then reach out to the owners of the repositories to let them know that they need to update their workflows. This can also be useful for repository admins as well, and just feed in the list of repositories that you manage. + +Going forward, make sure to set up [`dependabot.yml`{: .filepath}](https://josh-ops.com/posts/github-dependabot-for-actions/#marketplace-actions) file to keep your actions up to date. This also applies for actions that are internal to your enterprise/organization, but with additional [configuration required](https://josh-ops.com/posts/github-dependabot-for-actions/#custom-actions-in-organization) (see my [post](https://josh-ops.com/posts/github-dependabot-for-actions/#custom-actions-in-organization) for more details!). + +Also, check out [another user's solution](https://github.com/orgs/community/discussions/49405#discussioncomment-5227815) to the problem I'm solving here! diff --git a/_posts/2023-03-15-dependabot-reusable-workflows.md b/_posts/2023-03-15-dependabot-reusable-workflows.md new file mode 100644 index 0000000..e0626cc --- /dev/null +++ b/_posts/2023-03-15-dependabot-reusable-workflows.md @@ -0,0 +1,102 @@ +--- +title: 'Configuring Dependabot for Reusable Workflows in GitHub' +author: Josh Johanning +date: 2023-03-15 6:30:00 -0500 +description: Configuring Dependabot to keep Reusable Workflows up to date in GitHub +categories: [GitHub, Dependabot] +tags: [GitHub, Dependabot, Pull Requests, GitHub Actions, Reusable Workflows] +media_subpath: /assets/screenshots/2023-03-15-dependabot-reusable-workflows +image: + path: dependabot-pr.png + width: 100% + height: 100% + alt: A Dependabot-created pull request for a reusable workflow version update +--- + +## Overview + +We already can use [Dependabot Version Updates](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/about-dependabot-version-updates) for keeping marketplace actions (in addition to custom internal/private actions) up to date (see my [post](/posts/github-dependabot-for-actions/)) for more details). However, as of [March 2023](https://github.blog/changelog/2023-03-13-dependabot-updates-support-reusable-workflows-for-github-actions/), we can use Dependabot for keeping Reusable Workflows up to date as well. + +## Configuration + +### Authorization + +My previous [post](https://github.blog/changelog/2023-03-13-dependabot-updates-support-reusable-workflows-for-github-actions/) discusses how there are two ways that you can configure Dependabot when working with resources in internal or private repositories. To summarize, you can either: + +1. When Dependabot can't access a private repository, the logs allow you to [grant authorization to Dependabot to access your repository](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/managing-security-and-analysis-settings-for-your-organization#allowing-dependabot-to-access-private-dependencies) + - Alternatively, add it in the organization settings --> Code security and analysis --> [Grant Dependabot access to private repositories](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/managing-security-and-analysis-settings-for-your-organization#allowing-dependabot-to-access-private-dependencies) + - This setting requires organization admin permissions to access + - Unfortunately, there isn't an API to automate this process, it has to be done within the UI +2. If you plan to create a large number repositories that you want to be a source for Dependabot, creating a [Dependabot secret](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/configuring-access-to-private-registries-for-dependabot#storing-credentials-for-dependabot-to-use) (preferably as an org-level Dependabot secret) with the value of a [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) (preferably a [fine-grained token](https://github.blog/2022-10-18-introducing-fine-grained-personal-access-tokens-for-github/)) that has read-access to the required repositories would be preferred + - I don't find it as detrimental to use a personal access token as a Dependabot secret since Dependabot secrets can *only be access by Dependabot*; you can't use a GitHub Actions workflow to expose the secret accidentally/intentionally + - The only concern would be updating the token if it expires, is revoked, or the original author doesn't have access to the repo(s) anymore + +If you only have a few, and rarely increasing set of repositories for custom actions / reusable workflows, I recommend the first approach. If you have a large number of repositories and/or are creating many new repositories for actions / reusable workflows, the **second option scales better**. + +### YML + +For the **first approach** (authorizing Dependabot access manually), the YML configuration is no different than if you were using Dependabot to keep [marketplace actions up to date](/posts/github-dependabot-for-actions/). + +```yml +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Workflow files stored in the default location of `.github/workflows` + schedule: + interval: "daily" + open-pull-requests-limit: 5 +``` +{: file='.github/dependabot.yml'} + +You will then have to check your [Dependabot run logs](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/troubleshooting-dependabot-errors#investigating-errors-with-dependabot-version-updates) to authorize Dependabot for that repository (or [add it via the organization settings](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/managing-security-and-analysis-settings-for-your-organization#allowing-dependabot-to-access-private-dependencies)): +![Noticing a Dependabot run failure](dependabot-error.png){: .shadow } +_A Dependabot failure since it is unable to access private/internal repositories by default_ + +![Granting Dependabot access via the Dependabot logs](dependabot-grant-access.png){: .shadow } +_Granting access to Dependabot for this repository_ + +Once you grant Dependabot access to the repository, you will see it show up in organization settings –> Code security and analysis –> [Grant Dependabot access to private repositories](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/managing-security-and-analysis-settings-for-your-organization#allowing-dependabot-to-access-private-dependencies). Additional repositories can be added here: +![The repository now shows up under the organization --> Code security and analysis settings --> Grant Dependabot access to private repositories](dependabot-private-repositories.png){: .shadow } +_This repository now shows up under organization settings –> Code security and analysis –> Grant Dependabot access to private repositories_ + +For the **second option** (using a Dependabot secret), you will need to add the `registries` property to the YML configuration. The `registries` will reference `type: git` and use a [Dependabot Secret](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/configuring-access-to-private-registries-for-dependabot#storing-credentials-for-dependabot-to-use) (preferably an org-level Dependabot secret): + +{% raw %} +```yml +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" # Workflow files stored in the default location of `.github/workflows` + schedule: + interval: "daily" + registries: + - github +registries: + github: + type: git + url: https://github.com + username: x-access-token # username doesn't matter + password: ${{ secrets.GHEC_TOKEN }} # dependabot secret +``` +{: file='.github/dependabot.yml'} +{% endraw %} + +### Results + +If things are working properly, you should see a successful run in your [Dependabot run logs](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/troubleshooting-dependabot-errors#investigating-errors-with-dependabot-version-updates): + +![Example using Checks to create a line annotation](dependabot-success.png){: .shadow } +_Dependabot is able to access our repositories_ + +And if there is a new semver version of a reusable workflow, you should see a Dependabot-created pull request: + +![Example using Checks to create a line annotation](dependabot-full.png){: .shadow } +_Example of a pull request for reusable workflow created by Dependabot_ + +> Pro-tip: You can reply `@dependabot merge` or `@dependabot squash and merge` (among other commands) to tell Dependabot to merge the pull request. +{: .prompt-tip } + +## Summary + +Now we can create and properly version reusable workflows AND have our downstream users automatically be notified of version updates. This helps a ton in making it front and center for developers that there's an update they need to look at! 🎉 diff --git a/_posts/2023-03-22-github-script-to-create-teams.md b/_posts/2023-03-22-github-script-to-create-teams.md new file mode 100644 index 0000000..75833a8 --- /dev/null +++ b/_posts/2023-03-22-github-script-to-create-teams.md @@ -0,0 +1,91 @@ +--- +title: 'GitHub: Scripts to Mass Create and Delete Teams' +author: Josh Johanning +date: 2023-03-22 14:00:00 -0500 +description: Create and delete GitHub teams programmatically from a CSV file +categories: [GitHub, Scripts] +tags: [GitHub, Scripts, gh cli] +media_subpath: /assets/screenshots/2023-03-22-github-script-to-create-teams +image: + path: create-team.png + width: 100% + height: 100% + alt: Creating a team in GitHub +--- + +## Overview + +If you've ever had to create several teams in GitHub, you know how painful it can be clicking around manually in the GitHub interface, especially if you have to create child teams. These scripts aim to simplify that by providing a list of teams that you want to create (or delete) in a CSV, and then looping through each team and creating (or deleting) it with the [`gh api`](https://cli.github.com/manual/gh_api) command of the [`gh cli`](https://cli.github.com/). + +## The Scripts + +These scripts are in my [github-misc-scripts](https://github.com/joshjohanning/github-misc-scripts) repo: + +- [`create-teams-from-list.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/create-teams-from-list.sh) +- [`delete-teams-from-list.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/delete-teams-from-list.sh) + +## Using the Scripts + +### Prerequisites + +- You need to make sure to have the [`gh cli`](https://cli.github.com/) installed and authorized (`gh auth login`). +- Add the `admin:org` scope: + ```bash + gh auth refresh -h github.com -s admin:org + ``` + {: .nolineno} + +### Example Input File + +An example of input file that can be used for both creating/deleting teams: + +```sh +test11-team +test22-team +test11-team/test11111-team +test11-team/test11111-team/textxxx-team +test33-team + +``` +{: file='teams.csv'} + +> Note: Ensure that the input file has a trailing new line +{: .prompt-info } + +### Create Teams + +1. Prepare a list of teams that you want to create and place in a CSV file, one per line, with the last line empty. + - Child teams should have a slash in the name, e.g., `test1-team/test1-1-team` + - Build out the parent structure in the input file before creating the child teams; e.g. have the `test1-team` come before `test1-team/test1-1-team` in the file +2. From there, run the script by passing in the `teams.csv`{: .filepath} file and org name: + +```bash +./create-teams-from-list.sh teams.csv my-org +``` +{: .nolineno} + +> Note: A parent team should exist before creating a child team, or at least should come first in the input file +{: .prompt-info } + +### Delete Teams + +1. Prepare a list of teams that you want to create and place in a CSV file, one per line, with the last line empty. + - Child teams should have a slash in the name, e.g., `test1-team/test1-1-team` + - `!!! Important !!!` Note that if a team has child teams, all of the child teams will be deleted as well +2. From there, run the script by passing in the `teams.csv`{: .filepath} file and org name: + +```bash +./delete-teams-from-list.sh teams.csv my-org +``` +{: .nolineno} + +> Note: All child teams belonging to the parent team will be deleted as well +{: .prompt-danger } + +## Summary + +I originally built the `create-teams-from-list.sh`{: .filepath} script from a request from a blog reader, and then later built the delete teams script to simplify testing because if I was creating teams in an automated fashion, you know I had no interest in deleting the test teams manually 😀. + +Please feel free to PR or share any improvements to these scripts! The terminal logging certainly isn't the cleanest, but the scripts work and provide a decent amount of information that is relevant to the request. + +Enjoy! 🚀 diff --git a/_posts/2023-06-21-github-signing-commits.md b/_posts/2023-06-21-github-signing-commits.md new file mode 100644 index 0000000..69bf524 --- /dev/null +++ b/_posts/2023-06-21-github-signing-commits.md @@ -0,0 +1,86 @@ +--- +title: 'How to Sign Commits for GitHub' +author: Josh Johanning +date: 2023-06-21 15:00:00 -0500 +description: Signing commits locally to show up as verified commits in GitHub +categories: [GitHub, Commits] +tags: [GitHub, Git, Commits] +media_subpath: /assets/screenshots/2023-06-21-github-signing-commits +image: + path: verified-commits.png + width: 100% + height: 100% + alt: Verified commits in GitHub +--- + +## Overview + +This post will cover how to sign commits locally so that they show up as verified in GitHub. This can be important because in GitHub (as well as other Git platforms), it uses the **email** in your local git config to match the commit author to the username. Therefore, it's possible to spoof commits by changing the email to someone else's in your local git config. Signing your commits will ensure that the commits are coming from the correct user and that they haven't been spoofed. + +If you're editing via the web, the commits will already show up as **Verified** since the UI of course knows about your identity already. However, when you make commits locally, they won't show up as Verified by default without signing them. + +There are a couple of ways to sign commits: + +1. Use a [GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#gpg-commit-signature-verification) +2. Use an [SSH key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#ssh-commit-signature-verification) (if you already use the SSH protocol to connect to GitHub, add the same key as a signing key in GitHub) +3. Use an [S/MIME x.509 certificate](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#smime-commit-signature-verification) issued by your organization + +This tutorial will cover the first two option: using a GPG key and using an SSH key. Regardless of which option you choose, all signing options can be used for both HTTPS and SSH git protocols. GPG keys allow for an expiration date to be set when created whereas SSH keys do not. + +See my co-worker Ken Muse's [post](https://www.kenmuse.com/blog/comparing-github-commit-signing-options/) for more advanced and considerations for the various commit signing options. + +## Using a GPG Key + +1. Generate GPG key locally (ie: run in git bash if in windows): `gpg --full-generate-key` + - You can accept the defaults (use enter) for type of key, elliptic curve, and expiration - you will have to type `y` to confirm + - Enter your **name** for the **Real name** field + - Enter your **email** + - Important: The **EMAIL** field should be the **primary email** on the on the user's GitHub account + - If you keep your primary email private, use the `@users.noreply.github.com` email address shown to you in your [email settings page](https://github.com/settings/emails) + - **Comment** is optional + - Confirm the information by typing `O` and then enter + - As a best practice, add a **passphrase** to the GPG key when prompted +2. List the keys: `gpg --list-secret-keys --keyid-format=long` + - For example, this returns a string like `rsa3072/1BB5F381EEE9CC5A`, `1BB5F381EEE9CC5A` is the value you are looking for (the value after the `/`) - this is the GPG key ID +3. Add the GPG key ID to your local git config: `git config --global user.signingkey 1BB5F381EEE9CC5A` +4. Retrieve the entire public key using the GPG key ID: `gpg --armor --export 1BB5F381EEE9CC5A` + - On macOS, you can add `| pbcopy` to copy the result to clipboard +5. Add the public GPG key to your [GitHub profile under the GPG keys section](https://github.com/settings/keys) +6. . If you have used an alternative method to sign commits (like SSH), run this to set GPG back to the default: + - `git config --global --unset gpg.format` +7. Sign your commits: + - Add this to your git config to sign all new commits: `git config --global commit.gpgsign true` + - Otherwise, sign the commit manually when committing: `git commit -S -m "commit message"` +8. When committing, you should be prompted for the key's passphrase (if you added one in step #1 above) +9. After you push, you should see the verified tag on your commit in GitHub + - You can click on the verified tag to see the GPG key that was used to sign the commit (same in step #2 above) + +> Note: In macOS, I received an error when committing ("*gpg: signing failed: Inappropriate ioctl for device*") until I ran: `export GPG_TTY=$TTY`. +> It is recommended to add this to the **top** of your shell profile (ie: `~/.bash_profile`{: .filepath } or `~/.zshrc`{: .filepath }) +{: .prompt-info } + +> Note: In WSL, I was working with someone who received an error when committing ("*error: gpg failed to sign the data*, *fatal: failed to write commit object*") until I ran: `export GPG_TTY=$(tty)`. +> It is recommended to add this to the **top** of your shell profile (ie: `~/.bash_profile`{: .filepath } or `~/.zshrc`{: .filepath }) +{: .prompt-info } + +### Using an SSH Key + +1. If you don't already have one, generate an SSH key: `ssh-keygen -t ed25519 -C "your_email@example.com"` + - As a best practice, add a **passphrase** to the SSH key when prompted +2. Add the key to your local git config: `git config --global user.signingkey ~/.ssh/id_ed25519.pub` +3. Tell git to use the SSH key instead of GPG: `git config --global gpg.format ssh` +5. Display the public key: `cat ~/.ssh/id_ed25519.pub` +6. Copy and paste to add the public SSH key to your [GitHub profile under the SSH keys section](https://github.com/settings/keys) - add it as an SSH signing key +7. Optionally, add this to your local git config to sign every commit you create: `git config --global commit.gpgsign true` + - Otherwise, sign the commit manually when committing: `git commit -S -m "commit message"` +8. When committing, you should be prompted for the key's passphrase (if you added one in step #1 above) +9. After you push, you should see the verified tag on your commit in GitHub + - You can click on the verified tag to see the fingerprint of the SSH key that was used to sign the commit + +## Summary + +It's relatively easy to sign commits, so there really isn't an excuse to not do so! 🔐 ✅ + +![Verified commits](verified-commits.png){: .shadow }{: .light } +![Verified commits](verified-commits-dark-mode.png){: .shadow }{: .dark } +_Example of a verified commit in GitHub_ diff --git a/_posts/2023-06-21-storing-certificates-as-github-secrets.md b/_posts/2023-06-21-storing-certificates-as-github-secrets.md new file mode 100644 index 0000000..53e297e --- /dev/null +++ b/_posts/2023-06-21-storing-certificates-as-github-secrets.md @@ -0,0 +1,92 @@ +--- +title: 'Using GitHub Actions Secrets to Store Certificates/Keys' +author: Josh Johanning +date: 2023-06-21 16:00:00 -0500 +description: Storing a certificate/private key as a GitHub Actions secret +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions] +media_subpath: /assets/screenshots/2023-06-21-storing-certificates-as-github-secrets +image: + path: secrets.png + width: 100% + height: 100% + alt: Secrets stored in a repository with a base64-encoded value +--- + +## Overview + +In Azure DevOps, if you wanted to store a certificate, you would use a "[Secure Files](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/secure-files?view=azure-devops)" feature. GitHub doesn't have the same native functionality, but you can still store the value of a certificate as a secret to be used in your GitHub Actions workflows. Let's see some approaches. + +## Storing the Value of a Key/Certificate + +When storing an unencrypted key/certificate, you can simply grab the contents of the `.pem`{: .filepath } and store it as a secret in GitHub. Then, you can write the value of the secret to a file and use in your GitHub Actions workflows. + +Sample steps: + +1. Display/copy the contents of the `.pem`{: .filepath } file: `cat private-key.pem` +2. Add the value of the `.pem`{: .filepath } file as an [Action secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) in GitHub +3. In the workflow, add a step to write the value of the secret to a file: + +{% raw %} + +```yml +- name: save secret to file + run: | + echo $PRIVATE_KEY > private-key.pem + env: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} +``` + +Easy, right? + +> Note: This method works well for secret values that can be copied/pasted as plaintext. This method does not work well for binary files, such as `.p12`{: .filepath } files - see the next section! +{: .prompt-info } + +> Note: There is also a [CLI](https://cli.github.com/manual/gh_secret) command for setting secrets: +> ```sh +> gh secret set SIGNING_CERTIFICATE_BASE64 --body $value -R myorg/myrepo +> ``` +> {: .nolineno} +{: .prompt-tip } + +## Storing the Value of a File + +For encrypted or binary files, such as `.p12`{: .filepath } certificates, you can `base64` the entire file and store the `base64` value as a secret in GitHub. Then, you can decode the value of the secret to a file and use in your GitHub Actions workflows. + +Sample steps: + +1. Use the `base64` command to encode the file: `base64 ./my-certificate.p12` + - On macOS, you may have to use: `base64 -i ./my-certificate.p12` + - There is also a PowerShell option: `[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes("path/to/file"))` +2. Add the `base64` value as an [Action secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) in GitHub +3. In the workflow, add a step to write the value of the secret to a file: + +```yml +- name: save secret to file + run: | + echo -n $SIGNING_CERTIFICATE_BASE64 | base64 -d -o ./my-certificate.p12 + env: + SIGNING_CERTIFICATE_BASE64: ${{ secrets.SIGNING_CERTIFICATE_BASE64 }} +``` + +This is pretty much the exact same thing with the added step of decoding the `base64` value to a file. GitHub has additional documentation on storing binary content as `base64` secrets [here](https://docs.github.com/en/actions/security-guides/encrypted-secrets#storing-base64-binary-blobs-as-secrets). + +> Note: This method will work regardless of the type of file you're storing. +{: .prompt-info } + +{% endraw %} + +## Notes + +There are a few things to consider: + +- The text of the secret is limited to 48 KB (workaround using [`gpg` encryption](https://docs.github.com/en/actions/security-guides/encrypted-secrets#storing-large-secrets)) +- You can store up to 1,000 organization secrets, 100 repository secrets, and 100 environment secrets +- A workflow created in a repository can access the following number of secrets: + - All 100 repository secrets + - If the repository is assigned access to more than 100 organization secrets, the workflow can only use the first 100 organization secrets (sorted alphabetically by secret name) + - All 100 environment secrets + +## Summary + +Whether you're storing the private key of a GitHub App or storing the signing and distribution certificates for an iOS build, you can use GitHub Actions secrets to store the value of a certificate/private key and use it in your workflows. 🚀 diff --git a/_posts/2023-06-22-github-actions-tokenization.md b/_posts/2023-06-22-github-actions-tokenization.md new file mode 100644 index 0000000..71dcf5b --- /dev/null +++ b/_posts/2023-06-22-github-actions-tokenization.md @@ -0,0 +1,140 @@ +--- +title: 'Tokenization / Replacing Environment Tokens in GitHub Actions' +author: Josh Johanning +date: 2023-06-22 16:30:00 -0500 +description: Replacing environment-specific configuration at deployment time +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions] +media_subpath: /assets/screenshots/2023-06-22-github-actions-tokenization +image: + path: tokenization.png + width: 100% + height: 100% + alt: Replacing environment-specific configuration at deployment time +--- + +## Overview + +I've been thinking about the "build once, deploy many" concept lately, and how to accomplish this in GitHub Actions. We definitely don't want to be creating a "dev" build, testing it, then creating a new "prod" build and deploying that. They are two separate binaries; there is no way to verify that what we tested in dev is what is shipped to prod. We want to create a single build and deploy that to multiple environments. + +In Azure DevOps, I primarily used [Colin's ALM Corner Build & Release Tools](https://marketplace.visualstudio.com/items?itemName=colinsalmcorner.colinsalmcorner-buildtasks) Replace Tokens task or the standalone [Replace Tokens](https://marketplace.visualstudio.com/items?itemName=qetza.replacetokens) extension and task to find/replace environment-specific files during a deployment. I was exploring how to do this in GitHub Actions, and I think I found a way to mostly recreate this pattern. + +I'm using the [lindluni/actions-variable-groups](https://github.com/marketplace/actions/github-actions-variable-groups) and [cschleiden/replace-tokens](https://github.com/marketplace/actions/replace-tokens) actions to accomplish this. The `actions-variable-groups` action allows you to store the values of the (non-secret) variables to a file (variables-as-code!) in the repository, and the `replace-tokens` action does the find-and-replacing. + +## The Workflow + +Here is the workflow: + +{% raw %} + +```yml + dev: + runs-on: ubuntu-latest + environment: dev + needs: build + + steps: + - uses: actions/checkout@v3 + + - name: Inject Variables + uses: lindluni/actions-variable-groups@v2 + with: + groups: | + .github/variables/dev.yml + + - uses: cschleiden/replace-tokens@v1 + with: + tokenPrefix: '#{' + tokenSuffix: '}#' + files: '["**/*_tokenized.json"]' + + - name: replace app settings + run: | + rm appsettings.json + mv appsettings_tokenized.json appsettings.json +``` + +There is an alternative to the `actions-variable-groups` action where you instead use [configuration variables](https://docs.github.com/en/actions/learn-github-actions/variables#creating-configuration-variables-for-an-environment) defined on a repository's environment. I prefer the `actions-variable-groups` action for a few reasons: + +1. It allows you to store the variables in the repository as code, so it's easier to diff/review changes +2. Non-admins can view/modify the variables +3. You have to map in each environment variable to the workflow, which is a bit more cumbersome than just specifying the file to inject + +If you wanted to see how it would look using configuration variables, it would look something like this: + +```yml + dev: + runs-on: ubuntu-latest + environment: dev + needs: build + + steps: + - uses: actions/checkout@v3 + + - uses: cschleiden/replace-tokens@v1 + env: + APIURL: ${{ vars.APIURL }} + VAR2: ${{ vars.VAR2 }} + VAR3: ${{ vars.VAR3 }} + with: + tokenPrefix: '#{' + tokenSuffix: '}#' + files: '["**/*_tokenized.json"]' + + - name: replace app settings + run: | + rm appsettings.json + mv appsettings_tokenized.json appsettings.json +``` + +## Secrets? + +Secrets should not be stored in the repository (obviously). Create these as secrets and map them in as environment variables, like so: + +```yml + dev: + runs-on: ubuntu-latest + environment: dev + needs: build + + steps: + - uses: actions/checkout@v3 + + - name: Inject Variables + uses: lindluni/actions-variable-groups@v2 + with: + groups: | + .github/variables/dev.yml + + - uses: cschleiden/replace-tokens@v1 + env: + APIKEY: ${{ secrets.APIKEY }} + with: + tokenPrefix: '#{' + tokenSuffix: '}#' + files: '["**/*_tokenized.json"]' + + - name: replace app settings + run: | + rm appsettings.json + mv appsettings_tokenized.json appsettings.json +``` + +{% endraw %} + +Note that if you are injecting a secret into a raw file, ensure that wherever this file is ending up is not somewhere where it can be accessed by the general viewing public. If using self-hosted runners, it is a good idea to have a step that always runs to delete the tokenized file. + +## iOS? + +You can even do this with compiled iOS IPA files. An IPA is just a fancy zip, so we can extract it, replace values, re-zip as a `.ipa`{: .filepath} file, and re-sign it. + +I'm going to briefly outline this here; I don't have an app to test with at the moment, so some things might need to be tweaked slightly (such as unzip/zip folder paths): + +- Here is a sample [gist](https://gist.github.com/joshjohanning/15e2bda76687d353a50211a7477de370#file-deploy-yml-L18:L22) as to not bog down this post too much + +> Note: You can see how I did this with a real-world app in Azure DevOps in a [deployment pipeline here](https://github.com/joshjohanning/pipeline-templates/blob/main/ios/ios-deploy.yml). +{: .prompt-info } + +## Summary + +This is essentially the "GitHub" flavor of Colin's [Config Per Environment vs Tokenization in Release Management](https://colinsalmcorner.com/config-per-environment-vs-tokenization-in-release-management/) and [End to End Walkthrough: Deploying Web Applications Using Team Build and Release Management](https://colinsalmcorner.com/end-to-end-walkthrough-deploying-web-applications-using-team-build-and-release-management/) posts from many years ago. Hopefully this helps give you an idea of how you can accomplish this in GitHub Actions. diff --git a/_posts/2023-09-07-add-files-to-git-lfs.md b/_posts/2023-09-07-add-files-to-git-lfs.md new file mode 100644 index 0000000..8649fa4 --- /dev/null +++ b/_posts/2023-09-07-add-files-to-git-lfs.md @@ -0,0 +1,71 @@ +--- +title: 'Adding Files to Git LFS' +author: Josh Johanning +date: 2023-09-07 16:00:00 -0500 +description: Tracking new files in Git LFS +categories: [GitHub, Commits] +tags: [GitHub, Git, Git LFS, Commits] +media_subpath: /assets/screenshots/2023-09-07-add-files-to-git-lfs +image: + path: ./../2023-09-07-migrate-to-git-lfs/git-lfs-light.png + width: 100% + height: 100% + alt: Git LFS file in GitHub +--- + +## Overview + +Git LFS can be very helpful to store large files along with your code in Git repositories. Git LFS artifacts are treated like any other file in Git, but the contents of the file are stored in a separate location. This allows you to store large files in Git without bloating the size of your Git repository. Tracking new files in Git LFS is easy, but there are a few steps you need to follow when using LFS for the first time. + +> See my other Git LFS posts: +> - [Migrating Git Repos with LFS Artifacts](/posts/migrate-git-lfs-artifacts/) +> - [Migrating Large Files in Git to Git LFS](/posts/migrate-to-git-lfs/) +{: .prompt-info } + +## Prerequisites + +1. Install `git lfs` + - On macOS, you can install via `brew install git-lfs` + - On Windows, `git lfs` should be installed with [Git for Windows](https://gitforwindows.org/), but you can install an updated version via `choco install git-lfs` +2. Once installed, you need to run `git lfs install` to configure Git hooks to use LFS. + +## Adding a New File to Git LFS + +Here is how to add/track a new file to Git LFS: + +```bash +git lfs install # if git config is not currently configured for LFS +git lfs track "*.jpg" "*.png" # track all jpg and png files (recursively) +git add .gitattributes **/*.jpg **/*.png # add .gitattributes and LFS artifacts (recursively) +git lfs ls-files # optional: ensure your LFS files are tracked before committing +git commit -m "Tracking and adding LFS artifacts" +git push +``` + +The `git lfs install` command is important otherwise the files will not be pushed to LFS, even with a `.gitattributes`{: .filepath} file. If you don't want to install globally, you can run `git lfs install --local` to only configure the LFS Git hooks for the current repository. You can always run `git lfs uninstall` to unconfigure. + +Once you track the file(s) you want to add to LFS with the `git lfs track` command, you simply have to stage with `git add`, `commit`, and `push` as normal. Now, every additional `.jpg`{: .filepath} and `.png`{: .filepath} added to the repository will automatically be tracked by LFS. + +Of course, you can track specific files without using wildcards, as well as only tracking a specific directory. For example: + +```bash +git lfs track "file.exe" # track a single file +git lfs track "bin/*" # track a directory +``` + +Here is an example of adding files to Git LFS: +![Adding files to Git LFS](git-lfs-add.png){: .shadow } +_Adding files to Git LFS_ + +After pushing to GitHub, the GitHub UI shows the file is stored with Git LFS: +![Git LFS file in GitHub](./../2023-09-07-migrate-git-lfs-artifacts/git-lfs-light.png){: .shadow }{: .light } +![Git LFS file in GitHub](./../2023-09-07-migrate-git-lfs-artifacts/git-lfs-dark.png){: .shadow }{: .dark } +_File stored in Git LFS file in GitHub_ + +## Summary + +Like anything, there is a ton of documentation on the internet but sometimes it is a bit scattered. I created this post to help with those who are new to Git LFS and want to start tracking files. + +There are some useful tips I've picked up along the way, like using `git lfs ls-files` before committing to verify files are being tracked, and also avoiding a common pitfall of not running `git lfs install` before tracking files. + +I hope this helps! ✨ diff --git a/_posts/2023-09-07-migrate-git-lfs-artifacts.md b/_posts/2023-09-07-migrate-git-lfs-artifacts.md new file mode 100644 index 0000000..2201ca7 --- /dev/null +++ b/_posts/2023-09-07-migrate-git-lfs-artifacts.md @@ -0,0 +1,78 @@ +--- +title: 'Migrating Git Repos with LFS Artifacts' +author: Josh Johanning +date: 2023-09-07 14:30:00 -0500 +description: How to migrate Git LFS artifacts from one repository to another +categories: [GitHub, Migrations] +tags: [GitHub, Migrations, Git, Git LFS] +media_subpath: /assets/screenshots/2023-09-07-migrate-git-lfs-artifacts +image: + path: git-lfs-pointer-light.png + width: 100% + height: 100% + alt: Missing Git LFS file (pointer) in GitHub +--- + +## Overview + +Git Large File Storage (LFS) replaces large files such as audio samples, videos, datasets, and graphics with text pointers inside Git, while storing the file contents on a remote server like GitHub. Git LFS works seamlessly, you hardly know it's there when interacting with Git. However, there are special instructions that you need to follow if you are migrating Git repositories that contain LFS artifacts. + +> See my other Git LFS posts: +> - [Migrating Large Files in Git to Git LFS](/posts/migrate-to-git-lfs/) +> - [Adding Files to Git LFS](/posts/add-files-to-git-lfs/) +{: .prompt-info } + +## Prerequisites + +1. Install `git lfs` + - On macOS, you can install via `brew install git-lfs` + - On Windows, `git lfs` should be installed with [Git for Windows](https://gitforwindows.org/), but you can install an updated version via `choco install git-lfs` +2. Once installed, you need to run `git lfs install` to configure Git hooks to use LFS. + +## Migrating a Repository with LFS Artifacts + +If you are migrating a repository that contains LFS artifacts, you need to make sure that you migrate the LFS artifacts as well. If you don't, you will end up with a repository that contains text pointers to files that don't exist. This is because the LFS artifacts are stored in a separate location from the repository itself. + +Instructions: + +```bash +git clone --mirror temp +cd temp +git push --mirror +git lfs fetch --all +git lfs push --all +``` + +For Git repos with LFS, the two `git lfs` commands are key. This `git lfs fetch --all` command will download all the LFS artifacts from the source repository and store them in the target repository. The `git lfs push --all` command will push all of the LFS artifacts to the target repository. After the push, the text pointers will be updated - including the same commit hash! + +Here is an example of migrating a repository that contains LFS artifacts: +![Git LFS commands](git-lfs-commands.png){: .shadow } +_Running the migration commands to migrate a Git repository including Git LFS artifacts_ + +The repository has been migrated, including the LFS artifacts: +![Git LFS file in GitHub](git-lfs-light.png){: .shadow }{: .light } +![Git LFS file](git-lfs-dark.png){: .shadow }{: .dark } +_File stored in Git LFS file in GitHub_ + +## Alternative Method + +The method above worked well for me in my tests, even in tests with 1,000 LFS artifacts each 1mb in size. However, I have seen issues running these commands for some customers with larger repositories. Sometimes the `git lfs fetch` or `git lfs push` will hang or not appear to migrate all of the LFS artifacts. If you run into issues, you can run this script to more thoroughly (but slower) way to migrate the LFS artifacts. It works by looping through each LFS object and pushing it individually pushing it. + +The alternative script to migrate the LFS artifacts to another repository: + +```bash +git clone --mirror temp +cd temp +for object_id in $(git lfs ls-files --all --long | awk '{print $1}'); do + git lfs push --object-id remote "$object_id" +done +``` + +> Source for the [script](https://github.com/git-lfs/git-lfs/issues/4899#issuecomment-1688588756). +{: .prompt-info } + +## Summary + +Migrating Git repositories is simple. However, if the repository contains LFS artifacts, you need to make sure to run the `git lfs fetch` and `git lfs push` commands to migrate them along with the repository. If you don't, your Git LFS files will be missing. + +Now that your Git repo, including LFS artifacts, is migrated, you can delete the temp folder, update remotes on your local repo, and continue working as normal! 🚀 diff --git a/_posts/2023-09-07-migrate-to-git-lfs.md b/_posts/2023-09-07-migrate-to-git-lfs.md new file mode 100644 index 0000000..901c2c1 --- /dev/null +++ b/_posts/2023-09-07-migrate-to-git-lfs.md @@ -0,0 +1,79 @@ +--- +title: 'Migrating Large Files in Git to Git LFS' +author: Josh Johanning +date: 2023-09-07 15:00:00 -0500 +description: How to migrate Git LFS artifacts from one repository to another +categories: [GitHub, Migrations] +tags: [GitHub, Migrations, Git, Git LFS] +media_subpath: /assets/screenshots/2023-09-07-migrate-to-git-lfs +image: + path: git-lfs-light.png + width: 100% + height: 100% + alt: Git LFS file in GitHub +--- + +## Overview + +Git LFS can be very helpful to store large files along with your code in Git repositories. Git LFS artifacts are treated like any other file in Git, but the contents of the file are stored in a separate location. This allows you to store large files in Git without bloating the size of your Git repository. + +If you've ever tried migrating or pushing a repository that contains a file larger than 100mb, you might see something like this: + +> remote: error: File is 116.10 MB; this exceeds GitHub's file size limit of 100.00 MB +> remote: error: GH001: Large files detected. You may want to try Git Large File Storage - https://git-lfs.github.com. + +This post will show you how to migrate large blobs in Git to Git LFS. + +> See my other Git LFS posts: +> - [Migrating Git Repos with LFS Artifacts](/posts/migrate-git-lfs-artifacts/) +> - [Adding Files to Git LFS](/posts/add-files-to-git-lfs/) +{: .prompt-info } + +## Prerequisites + +1. Install `git lfs` + - On macOS, you can install via `brew install git-lfs` + - On Windows, `git lfs` should be installed with [Git for Windows](https://gitforwindows.org/), but you can install an updated version via `choco install git-lfs` +2. Once installed, you need to run `git lfs install` to configure Git hooks to use LFS. + +## Migrating a File to Git LFS + +If you have a file in the Git history is larger than 100mb, you can migrate it to Git LFS. This will allow you to store the file in Git LFS and keep the file in your Git repository. + +> Following these instructions will **rewrite Git history** (new commit hashes)! Make sure to have a COMPLETE backup of the repository before proceeding (`git clone --mirror `) +{: .prompt-danger } + +> For those using **commit signing**: I have noticed that since the `git lfs migrate import` rewrites history, the commits that it recreates aren't signed. +{: .prompt-warning } + +An example script to migrate `.exe`{: .filepath} and `.iso`{: .filepath} files to Git LFS: + +```bash +cd ~/Repos/my-git-repo-with-large-files +git lfs migrate import --include="*.exe, *.iso" --everything +git lfs ls-files # list LFS files +git push --all --force # force push all branches to remote +``` + +This will migrate all files with the extensions `.exe`{: .filepath} and `.iso`{: .filepath} to Git LFS. The `--everything` option will run the migration in all local and remote Git refs (branches, tags). Additionally, this will also create and commit a `.gitattributes`{: .filepath} file that will tell Git to store all files with the extensions `.exe`{: .filepath} and `.iso`{: .filepath} in Git LFS. + +The nice thing about the `--everything` option is that it also works for files that have been committed to history and subsequently deleted, so you don't have to use any additional tools to rewrite history. + +If you only want to migrate and rewrite a single branch, you can use `--include-ref refs/heads/main` to specify a specific ref. You can add multiple `--include-ref` options to rewrite and migrate multiple refs. + +Read more on the `git lfs migrate` command in the [docs](https://github.com/git-lfs/git-lfs/blob/main/docs/man/git-lfs-migrate.adoc#options). + +Here is an example of these commands in action. Note after the `git push --all --force` command, we can see that LFS objects are being uploaded: +![Git LFS commands](git-lfs-migrate-commands.png){: .shadow } +_Running the migration commands to migrate large/binary files to Git LFS_ + +Afterwards, the GitHub UI shows the file is now stored with Git LFS: +![Git LFS file in GitHub](./../2023-09-07-migrate-git-lfs-artifacts/git-lfs-light.png){: .shadow }{: .light } +![Git LFS file in GitHub](./../2023-09-07-migrate-git-lfs-artifacts/git-lfs-dark.png){: .shadow }{: .dark } +_File stored in Git LFS file in GitHub_ + +## Summary + +Since [Git LFS 2.2.0](https://github.blog/2017-06-27-git-lfs-2-2-0-released/), the `git lfs migrate` command makes it easy to migrate large files in a repository to Git LFS. Before, you would have had to use `git filter-repo` or [BFG Repo-Cleaner](https://rtyley.github.io/bfg-repo-cleaner/) to 1) remove the file from the Git history, then 2) add the files to be tracked by LFS by running `git lfs track`, 3) staging the `.gitattributes`{: .filepath} file and pushing. + +Now, with the simple `git lfs migrate` command, this takes care of most of the dirty work for us! 🎉 diff --git a/_posts/2023-09-08-github-packages-migrate-npm-packages.md b/_posts/2023-09-08-github-packages-migrate-npm-packages.md new file mode 100644 index 0000000..b6964dd --- /dev/null +++ b/_posts/2023-09-08-github-packages-migrate-npm-packages.md @@ -0,0 +1,97 @@ +--- +title: 'GitHub Packages: Migrate npm Packages Between GitHub Instances' +author: Josh Johanning +date: 2023-09-08 15:00:00 -0500 +description: Migrating npm packages stored in GitHub Packages from one instance to another +categories: [GitHub, Packages] +tags: [GitHub, Scripts, GitHub Packages, gh cli, npm, Migrations] +media_subpath: /assets/screenshots/2023-09-08-github-packages-migrate-npm-packages +image: + path: npm-packages-light.png + width: 100% + height: 100% + alt: npm packages in GitHub Packages +--- + +## Overview + +I have been working with more customers who are migrating GitHub instances and want to be able to migrate GitHub Packages. There is not an easy lift-and-shift approach to migrate GitHub Packages between instances; each ecosystem is independent. To go along with my [NuGet](/posts/github-packages-migrate-nuget-packages/) solution, I also scripted out the npm package migration. Take a look and let me know what you think! + +> See my other GitHub Package --> GitHub Package migration posts: +> +> - [Migrate NuGet Packages Between GitHub Instances](/posts/github-packages-migrate-nuget-packages/) +> - [Migrate Maven Packages Between GitHub Instances](/posts/github-packages-migrate-maven-packages/) +> - [Migrate Docker containers Between GitHub Instances](/posts/github-packages-migrate-docker-containers/) +{: .prompt-info } + +## The script + +The script can be found in my [github-misc-scripts](/posts/github-misc-scripts/) repo here: + +- **[https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/migrate-npm-packages-between-github-instances.sh](https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/migrate-npm-packages-between-github-instances.sh)** + +## Running the script + +### Prerequisites + +1. [`gh cli`](https://cli.github.com) installed +2. Set the source GitHub PAT env var: `export GH_SOURCE_PAT=ghp_abc` (must have at least `read:packages`, `read:org` scope) +3. Set the target GitHub PAT env var: `export GH_TARGET_PAT=ghp_xyz` (must have at least `write:packages`, `read:org` scope) + +Notes: + +- This script assumes that the target org's repo name is the same as the source +- If the repo doesn't exist, the package will still import but won't be mapped to a repo + +### Usage + +You can call the script via: + +```bash +./migrate-npm-packages-between-github-instances.sh \ + \ + \ + \ + \ + | tee output.log +``` + +> The `| tee output.log` will print the output to the console and also save it to a file. You can refer back to the log file later and search for errors. +{: .prompt-tip } + +### Example + +An example of this in practice: + +```bash +export GH_SOURCE_PAT=ghp_abc +export GH_TARGET_PAT=ghp_xyz + +./migrate-npm-packages-between-github-instances.sh \ + joshjohanning-org \ + github.com \ + joshjohanning-org-packages \ + github.com \ + | tee output.log +``` + +## Notes + +- This script assumes that the target org's repo name is the same as the source +- If the repo doesn't exist, the package will still import but won't be mapped to a repo +- This script uses RegEx to find/replace source org with target org in the package's `package.json`{: .filepath} (see the [GitHub docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#publishing-a-package) for more info) +- To clean up the working directory when done, run this one-liner: + ```bash + rm -rf ./temp + ``` + {: .nolineno} + +## Improvement Ideas + +* [x] Add a source folder input instead of relying on current directory (just using `./temp`{: .filepath}) +* [ ] Map between repositories where the target repo is named differently than the source repo (likely this isn't needed since if repo doesn't exist, packages will still be pushed, the package just won't be linked to a repository) +* [x] Update script because of GitHub Packages GraphQL [deprecation](https://github.blog/changelog/2022-08-18-deprecation-notice-graphql-for-packages/) + +## Summary + +Drop a comment here or an issue or PR on my [github-misc-scripts repo](https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/migrate-npm-packages-between-github-instances.sh) if you have any feedback or suggestions! Happy packaging! 📦 diff --git a/_posts/2023-09-25-github-packages-migrate-maven-packages.md b/_posts/2023-09-25-github-packages-migrate-maven-packages.md new file mode 100644 index 0000000..38d3e2a --- /dev/null +++ b/_posts/2023-09-25-github-packages-migrate-maven-packages.md @@ -0,0 +1,97 @@ +--- +title: 'GitHub Packages: Migrate Maven Packages Between GitHub Instances' +author: Josh Johanning +date: 2023-09-25 14:30:00 -0500 +description: Migrating Maven packages stored in GitHub Packages from one instance to another +categories: [GitHub, Packages] +tags: [GitHub, Scripts, GitHub Packages, gh cli, Maven, Migrations] +media_subpath: /assets/screenshots/2023-09-25-github-packages-migrate-maven-packages +image: + path: maven-packages-light.png + width: 100% + height: 100% + alt: Maven packages in GitHub Packages +--- + +## Overview + +I have been working with more customers who are migrating GitHub instances and want to be able to migrate GitHub Packages. There is not an easy lift-and-shift approach to migrate GitHub Packages between instances; each ecosystem is independent. To go along with my [NuGet](/posts/github-packages-migrate-nuget-packages/) and [npm](/posts/github-packages-migrate-npm-packages/) solutions, I also scripted out the Maven package migration. Take a look and let me know what you think! + +> See my other GitHub Package --> GitHub Package migration posts: +> +> - [Migrate NuGet Packages Between GitHub Instances](/posts/github-packages-migrate-nuget-packages/) +> - [Migrate npm Packages Between GitHub Instances](/posts/github-packages-migrate-npm-packages/) +> - [Migrate Docker containers Between GitHub Instances](/posts/github-packages-migrate-docker-containers/) +{: .prompt-info } + +## The script + +The script can be found in my [github-misc-scripts](/posts/github-misc-scripts/) repo here: + +- **[https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/migrate-maven-packages-between-github-instances.sh](https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/migrate-maven-packages-between-github-instances.sh)** + +## Running the script + +### Prerequisites + +1. [`gh cli`](https://cli.github.com) installed +2. Set the source GitHub PAT env var: `export GH_SOURCE_PAT=ghp_abc` (must have at least `read:packages`, `read:org` scope) +3. Set the target GitHub PAT env var: `export GH_TARGET_PAT=ghp_xyz` (must have at least `write:packages`, `read:org`, `repo` scope) + +Notes: + +- Until Maven supports the new GitHub Packages type, `mvnfeed` requires the target repo to exist +- Link to [GitHub public roadmap item](https://github.com/github/roadmap/issues/578) +- This scripts creates the repo if it doesn't exist +- Otherwise, if the repo doesn't exist, receive "`example-1.0.5.jar`{: .filepath} was not found in the repository" error +- Currently [`mvnfeed-cli`](https://github.com/microsoft/mvnfeed-cli) only supports migrating `.jar`{: .filepath} and `.pom`{: .filepath} files (not `.war`{: .filepath}) + +### Usage + +You can call the script via: + +```bash +./migrate-maven-packages-between-github-instances.sh \ + \ + \ + \ + +``` + +### Example + +An example of this in practice: + +```bash +export GH_SOURCE_PAT=ghp_abc +export GH_TARGET_PAT=ghp_xyz + +./migrate-maven-packages-between-github-instances.sh \ + joshjohanning-org \ + github.com \ + joshjohanning-org-packages \ + github.com +``` + +## Notes + +- This script assumes that the target org's repo name is the same as the source +- If the repo doesn't exist, the package will still import but won't be mapped to a repo +- The script uses [`mvnfeed-cli`](https://github.com/microsoft/mvnfeed-cli) to migrate Maven packages to the target org +- Currently `mvnfeed-cli` only supports migrating `.jar`{: .filepath} + `.pom`{: .filepath} files (not `.war`{: .filepath}) +- To clean up the working directory when done, run this one-liner: + ```bash + rm -rf ./temp + ``` + {: .nolineno} + +## Improvement Ideas + +* [x] Add a source folder input instead of relying on current directory (just using `./temp`{: .filepath}) +* [ ] Map between repositories where the target repo is named differently than the source repo +* [x] Update script because of GitHub Packages GraphQL [deprecation](https://github.blog/changelog/2022-08-18-deprecation-notice-graphql-for-packages/) +* [ ] Fork [`mvnfeed-cli`](https://github.com/microsoft/mvnfeed-cli) to support migrating `.war`{: .filepath} files ([issue](https://github.com/microsoft/mvnfeed-cli/issues/16)) - this only supports `.jar`{: .filepath} files currently + +## Summary + +Drop a comment here or an issue or PR on my [github-misc-scripts repo](https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/migrate-maven-packages-between-github-instances.sh) if you have any feedback or suggestions! Happy packaging! 📦 diff --git a/_posts/2023-12-04-visual-studio-toolbox-github-actions.md b/_posts/2023-12-04-visual-studio-toolbox-github-actions.md new file mode 100644 index 0000000..ba9f9c3 --- /dev/null +++ b/_posts/2023-12-04-visual-studio-toolbox-github-actions.md @@ -0,0 +1,29 @@ +--- +title: 'Visual Studio Toolbox Live - DevOps with GitHub Actions' +author: Josh Johanning +date: 2023-12-04 15:00:00 -0600 +description: Watch the Visual Studio Toolbox Live recording as I show you how you can automate your builds, tests, and deployments with GitHub Actions +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions] +media_subpath: /assets/screenshots/2023-12-04-visual-studio-toolbox-github-actions +image: + path: visual-studio-toolbox-github-actions.jpg + width: 100% + height: 100% + alt: Visual Studio Toolbox Live - DevOps with GitHub Actions +--- + +## Overview + +I recently had the opportunity to hop on the [Visual Studio Toolbox Live channel](https://www.youtube.com/@visualstudio/streams) for a 2-part DevOps series: one session on GitHub Actions and one session on [Azure Pipelines](/posts/visual-studio-toolbox-azure-pipelines/). In this video, we introduce GitHub Actions, how it varies from Azure Pipelines, and create a workflow to build and deploy a sample web app to Azure. I had a great time with Leslie and Robert, and I hope you enjoy the videos! + +> Josh Johanning shows how you can automate your builds, tests, and deployments with GitHub Actions +> +> - Sample app repo: https://github.com/joshjohanning-org/tailspin-spacegame-web-demo +> - Learning resources: [Microsoft Learn](https://learn.microsoft.com/en-us/training/browse/?terms=github&resource_type=learning%20path), [GitHub Skills](https://skills.github.com/#automate-workflows-with-github-actions), and [GitHub Learning Pathways](https://resources.github.com/learn/pathways/automation/) +> - [Workflow syntax for GitHub Actions](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#about-yaml-syntax-for-workflows) + +{% include embed/youtube.html id='Ci3Uw92NV98' %} + +> See my other Visual Studio Toolbox Live video on Azure Pipelines [here](/posts/visual-studio-toolbox-azure-pipelines/)! +{: .prompt-info } diff --git a/_posts/2023-12-05-visual-studio-toolbox-azure-pipelines.md b/_posts/2023-12-05-visual-studio-toolbox-azure-pipelines.md new file mode 100644 index 0000000..3c31ace --- /dev/null +++ b/_posts/2023-12-05-visual-studio-toolbox-azure-pipelines.md @@ -0,0 +1,29 @@ +--- +title: 'Visual Studio Toolbox Live - DevOps with Azure Pipelines' +author: Josh Johanning +date: 2023-12-05 15:00:00 -0600 +description: Watch the Visual Studio Toolbox recording as I show you how you can automate your builds, tests, and deployments with Azure Pipelines +categories: [Azure DevOps, Pipelines] +tags: [Azure DevOps, Azure Pipelines] +media_subpath: /assets/screenshots/2023-12-05-visual-studio-toolbox-azure-pipelines +image: + path: visual-studio-toolbox-azure-pipelines.jpg + width: 100% + height: 100% + alt: Visual Studio Toolbox Live - DevOps with Azure Pipelines +--- + +## Overview + +I recently had the opportunity to hop on the [Visual Studio Toolbox Live channel](https://www.youtube.com/@visualstudio/streams) for a 2-part DevOps series: one session on [GitHub Actions](/posts/visual-studio-toolbox-github-actions/) and one session on Azure Pipelines. In this video, we introduce Azure Pipelines, discuss the various pipeline options (classic vs. YAML), compare Azure Pipelines to GitHub Actions, and create a pipeline to build and deploy a sample web app to Azure. I had a great time with Leslie and Robert, and I hope you enjoy the videos! + +> Josh Johanning returns to show how you can automate your builds, tests, and deployments with Azure Pipelines +> +> - Sample app repo: https://github.com/joshjohanning-org/tailspin-spacegame-web-demo +> - Learning resources: [Microsoft Learn](https://learn.microsoft.com/en-us/training/browse/?terms=azure%20pipelines&resource_type=learning%20path) +> - [YAML schema reference for Azure Pipelines](https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/?view=azure-pipelines) + +{% include embed/youtube.html id='8pQ4WBE8akE' %} + +> See my other Visual Studio Toolbox Live video on GitHub Actions [here](/posts/visual-studio-toolbox-github-actions/)! +{: .prompt-info } diff --git a/_posts/2023-12-18-github-enterprise-server-slack.md b/_posts/2023-12-18-github-enterprise-server-slack.md new file mode 100644 index 0000000..472669d --- /dev/null +++ b/_posts/2023-12-18-github-enterprise-server-slack.md @@ -0,0 +1,141 @@ +--- +title: 'Integrate GitHub Enterprise Server with Slack' +author: Josh Johanning +date: 2023-12-18 19:30:00 -0600 +description: Integrate GitHub Enterprise Server to receive notifications in Slack without opening up the firewall +categories: [GitHub, Integrations] +tags: [GitHub, Enterprise Server, Slack] +media_subpath: /assets/screenshots/2023-12-18-github-enterprise-server-slack +image: + path: ghes-slack-integration.png + width: 100% + height: 100% + alt: GitHub Enterprise Server integration with Slack +--- + +## Overview + +I recently had a customer ask if it was possible to natively integrate GitHub Enterprise Server with Slack. Right now, they are using custom actions in GitHub Actions and scripts in Jenkins to push CI/CD notifications to Slack. + +GitHub has a [Slack integration](https://github.com/integrations/slack), but the docs are slightly ambiguous to as whether it works with GitHub Enterprise Server without having to open up your network to allow inbound access from all of [Slack's URLs](https://github.slack.com/help/urls). The docs state: + +> Bidirectional connectivity between GHES and Slack: Our GHES integration is not just a notification service. It will also enable you to perform actions directly from chat. **So, the only prerequisite you need to ensure your GHES instance is accessible from Slack.** + +Slack has a [socket mode](https://api.slack.com/apis/connections/socket), which uses WebSockets initiated from the GitHub Enterprise Server instance to communicate with Slack. This is similar to how self-hosted runners work (well, slightly different, [self-hosted runners use long-polling](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#communication-between-self-hosted-runners-and-github)), which allow external communication without opening up inbound traffic in the firewall. + +But currently, the README has a seemingly ominous note regarding Slack socket mode: + +> **Slack Socket mode** +> +> Proxies not currently supported. + +I believe this to mean *Slack socket mode is supported*, but *if you're using a proxy, then it will not work*. + +Several other people have had similar [questions](https://github.com/integrations/slack/issues/1702) as to how and if this integration will work in GitHub with Slack socket mode enabled. The answer is yes, and I'll walk you through! + + +## Prerequisites + +1. GitHub Enterprise Server 3.8+ +2. Access to the GitHub Enterprise Server management console +3. Admin access in Slack to generate an **[App Configuration Token](https://api.slack.com/apps)** and **install an app into a workspace** + - Note that you have to install the Slack app to the workspace *from a link provided in the management console*, but more on that in a bit +4. [Organization owner](https://docs.github.com/en/organizations/managing-peoples-access-to-your-organization-with-roles/roles-in-an-organization#organization-owners) access to the GitHub org(s) that want to integrate with Slack to be able to install the GitHub Slack app + +## Integration Steps + +1. Navigate to the GitHub Enterprise Server management console, e.g. `https://github.example.com:8443/setup/settings` +2. Scroll down to the "Chat Integration" section and click "Enable GitHub Chat integration"; it should default to Slack, but if not, select the Slack radio button + ![Enable GitHub Chat integration](ghes-slack-integration-step-02.png){: .shadow } + _Enable GitHub Chat integration_ +3. Navigate to the [Slack API portal](https://api.slack.com/apps) +4. Click on the "Generate Token" button - this will generate a token that GitHub will use to *create an app for you* under your account + ![Generate Token](ghes-slack-integration-step-04.png){: .shadow } + _Click the generate token button to allow GitHub to register an app for you_ +5. Confirm the Slack workspace where the app will be created + ![Selecting the Slack workspace for the app](ghes-slack-integration-step-05.png){: .shadow } + _Selecting the Slack workspace for the app_ +6. Click the "Copy" button in the "Access Token" column for the newly created token + ![Copy the Access Token](ghes-slack-integration-step-06.png){: .shadow } + _Copy the newly generated access token_ +7. Paste in the access token, check the box for "Configure to use socket mode", and click "Generate App" + ![Pasting in the access token, selecting socket mode, and generating the app](ghes-slack-integration-step-07.png){: .shadow } + _Pasting in the access token, selecting socket mode, and generating the app_ +8. After a few moments, you should a message saying "Slack app generated successfully" + ![Slack app generated successfully](ghes-slack-integration-step-08.png){: .shadow } + _Slack app generated successfully_ +9. You will see a "Slack App ID" was generated - click the ID to follow the link to the Slack API portal + ![Slack App ID](ghes-slack-integration-step-09.png){: .shadow } + _A Slack APP ID was generated - follow the link_ +10. Scroll down to "App-Level Tokens" and click "Generate Token and Scopes" + ![Generate Token and Scopes](ghes-slack-integration-step-10.png){: .shadow } + _Generate Token and Scopes_ +11. Give the token a name and add the `connections:write` and `authorizations:read` scopes and click "Generate" + ![Creating a token with connections and authorizations access](ghes-slack-integration-step-11-1.png){: .shadow } + _Creating a token with `connections:write` and `authorizations:read` access_ +12. Copy the token + ![Copy the token](ghes-slack-integration-step-12-1.png){: .shadow } + _Copy the token_ +13. Paste the token in the "Slack app level token" field and click "Save" + ![Paste the Slack app level token and save](ghes-slack-integration-step-13.png){: .shadow } + _Paste the Slack app level token and click save_ +14. You should see a message indicating the Slack app level token was saved successfully + ![Slack app settings have been saved](ghes-slack-integration-step-14.png){: .shadow } + _Slack app settings have been saved_ +15. Click on the server's "Save Settings" button to save the changes - this shouldn't cause any downtime but it will take some time (15-30 minutes) for the changes to take effect + ![Save settings](ghes-slack-integration-step-15.png){: .shadow } + _Save settings and wait for the server to finish configuring_ +16. Once the server is finished configuring, navigate back to the management console +17. Scroll down to the "Chat Integration" section again; you should see "Chat integration is now available to be installed in the workspace" - follow the link! + - The URL: `https://github.example.com/_slack/` + ![From the management console, install the Slack app](ghes-slack-integration-step-17.png){: .shadow } + _From the management console, install the Slack app to the workspace_ +18. Click on the "Add to Slack" button + ![Add the Slack App](ghes-slack-integration-step-18.png){: .shadow } + _Add the Slack app_ +19. Install the app into the Slack workspace + ![Install the Slack App](ghes-slack-integration-step-19.png){: .shadow } + _Allow the Slack app to be installed to the Slack workspace_ +20. After it's installed, it will redirect you back to Slack + ![Redirected back to Slack](ghes-slack-integration-step-20.png){: .shadow } + _After installing the app, you will be redirected back to Slack_ +21. The redirect will take you to the `GHE` bot in Slack and will ask you to link your account to begin using + ![Connect your GitHub account to Slack by interacting with the `GHE` bot](ghes-slack-integration-step-21.png){: .shadow } + _Connect your GitHub account to Slack by interacting with the `GHE` bot_ +22. Click the button to connect your GitHub account to Slack + ![Authorize your GitHub Account with Slack to generate a code and paste it back into Slack](ghes-slack-integration-step-22.png){: .shadow } + _Authorize your GitHub Account with Slack by generating a code and pasting it back into Slack_ +23. Copy the verification code that you will paste back into Slack + ![Code for connecting your GitHub account to Slack](ghes-slack-integration-step-23.png){: .shadow } + _Code for connecting your GitHub account to Slack_ +24. Click the "Enter Token" button and paste in the code + ![Pasting in the verification code to complete the authentication](ghes-slack-integration-step-24.png){: .shadow } + _Pasting in the verification code to complete the authentication_ +25. Success! Your GitHub account is now linked to Slack + ![GitHub Enterprise user account is now connected to Slack](ghes-slack-integration-step-25.png){: .shadow } + _GitHub Enterprise user account is now connected to Slack_ +26. In a Slack channel, type and send `/ghe subscribe owner/repo` to subscribe to a repo's notifications - you'll notice that we are now prompted to install the Slack GitHub App to the repository now + ![Subscribing to a repo to a Slack channel](ghes-slack-integration-step-26.png){: .shadow } + _Subscribing to a repo to a Slack channel_ +27. Select the organization to install the Slack GitHub App to + ![Installing the Slack GitHub App to a GitHub organization](ghes-slack-integration-step-27.png){: .shadow } + _Installing the Slack GitHub App to a GitHub organization_ +28. Determine if you want to grant the Slack GitHub App access to all repositories or only select repositories + ![Grant access to all repositories or select repositories for the Slack GitHub App](ghes-slack-integration-step-28.png){: .shadow } + _Grant access to all repositories or select repositories for the Slack GitHub App_ +29. Assuming you have permissions (organization owner), the app should now be installed +30. Head back to the Slack channel and send `/ghe subscribe owner/repo` again - you should now see a message indicating that the channel is now subscribed to the repository + ![GitHub notifications in Slack](ghes-slack-integration-step-30.png){: .shadow } + _GitHub notifications in Slack_ +31. To only receive notifications for a [specific feature](https://github.com/integrations/slack/?tab=readme-ov-file#customize-your-notifications), use `/ghe subscribe owner/repo ` +32. You can also DM the `GHE` bot to subscribe to repositories directly to your DMs, as well as using commands such as `/ghe open owner/repo` or `/ghe close [issue link]` to open or close issues + +That's it! 🎉 + +## Summary + +Now, we can integrate GitHub natively with Slack to be able to receive rich notifications just like if we were using GitHub.com or GitHub Enterprise Cloud. The GitHub Enterprise Server instance that I was using for this post had firewall rules that prevented the outside world from accessing my instance, and I was able to prove that you don't need to create any special firewall rules to be able to use the Slack integration. + +all possible because of [Socket Mode](https://api.slack.com/apis/connections/socket) in Slack, which creates a WebSocket connection initiated by your GitHub Enterprise Server instance to Slack's servers to pass information bidirectionally. This is much better than building your own Actions or configuring your own webhooks! + +Enjoy the notifications! 📣 💬 diff --git a/_posts/2023-12-22-github-actions-oidc-reusable-workflows.md b/_posts/2023-12-22-github-actions-oidc-reusable-workflows.md new file mode 100644 index 0000000..3182c1c --- /dev/null +++ b/_posts/2023-12-22-github-actions-oidc-reusable-workflows.md @@ -0,0 +1,274 @@ +--- +title: 'Using OIDC with Reusable Workflows to Securely Access Cloud Resources' +author: Josh Johanning +date: 2023-12-22 11:30:00 -0600 +description: Using Reusable Workflows in GitHub Actions to standardize and security harden your deployment steps +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, OIDC, Azure, Reusable Workflows] +media_subpath: /assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows +image: + path: github-oidc-success-light.png + width: 100% + height: 100% + alt: Using OIDC in GitHub Actions to authenticate to Azure and retrieve secrets from a Key Vault +--- + +## Overview + +OpenID Connect (OIDC) is great for accessing resources by exchanging short-lived tokens directly to the thing you are trying to authenticate with (often a cloud provider but doesn't have to be!). GitHub Actions has [several examples for using OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) in workflows to be able to access resources like Azure, AWS, HashiCorp Vault, etc. Passwordless authentication is game-changing! + +In GitHub Actions, Reusable workflows are also great for providing consistency to workflows within an organization. Also, they prevent code duplication and simplifies making changes to workflows. + +These two features can be combined to provide a secure and consistent way to access cloud resources. + +For example, what if there was a secret that was required in every single workflow (such as a key to access a private Maven/NuGet/npm/Docker/etc. feed)? When using reusable workflows, that secret has to exist *on the caller workflow repo*, not on the **called* aka *reusable* workflow repo*. You either have to create an organization secret that has access to all repositories (which isn't ideal since that means anyone with write access to a repository can write some code to access that secret), or you have to create a secret on each repository that uses the reusable workflow. This is where the magic of [OIDC and reusable workflows](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/using-openid-connect-with-reusable-workflows) meet! + +This post will show you how to customize your subject claims on the GitHub repository pass in the reusable workflow to Azure to be able to authenticate to an Azure Key Vault and retrieve a secret. + +## The OIDC Subject Claim + +Following the [GitHub docs](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/using-openid-connect-with-reusable-workflows): + +> - Using `job_workflow_ref`: +> - To create trust conditions based on reusable workflows, your cloud provider must support custom claims for `job_workflow_ref`. This allows your cloud provider to identify which repository the job originally came from. +> - For clouds that only support the standard claims (audience (`aud`) and subject (`sub`)), you can use the API to customize the sub claim to include `job_workflow_ref`. For more information, see "[About security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-token-claims)". Support for custom claims is currently available for Google Cloud Platform and HashiCorp Vault. +> - Customizing the token claims: +> - You can configure more granular trust conditions by customizing the subject (`sub`) claim that's included with the JWT. For more information, see "[About security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-token-claims)". + +> - We can also see which claims are supported here: [https://token.actions.githubusercontent.com/.well-known/openid-configuration](https://token.actions.githubusercontent.com/.well-known/openid-configuration) +{: .prompt-tip } + +If you're not an OIDC expert (don't worry, I'm not either), this might not make a ton of sense, but don't worry, let's step through it. + +Let's first start by examining the subject (`sub`) claim that GitHub Actions generates by default. We can print out the token by copying a bash script step or using a ready-made action to debug the OIDC token claims. The action is a Docker action, which can make it harder to run on some hosts, so I am including both examples here: + +{% raw %} +```yml + print-oidc-token: + runs-on: ubuntu-latest + permissions: + id-token: write # this is needed for oidc + contents: read # this is needed to clone repo + steps: + + # debug using the action + - name: Debug OIDC Claims + uses: github/actions-oidc-debugger@main + with: + audience: '${{ github.server_url }}/${{ github.repository_owner }}' + + # print oidc token claims manually + - name: print oidc token claims + run: | + IDTOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL" -H "Accept: application/json; api-version=2.0" -H "Content-Type: application/json" | jq -r '.value') + jwtd() { + if [[ -x $(command -v jq) ]]; then + jq -R 'split(".") | .[1] | @base64d | fromjson' <<< "${1}" > jwt_claims.json + cat jwt_claims.json + echo ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL}} + fi + } + jwtd $IDTOKEN +``` +{: file='.github/workflows/debug-oidc-demo.yml'} +{% endraw %} + +By default, the `sub` of the OIDC token that GitHub Actions generates just looks something like this: + +```json +"sub": "repo:joshjohanning-org/standard-oidc-claim-demo:ref:refs/heads/main" +``` +{: .nolineno} + +Notice that there isn't anything special in there; just the repository that is running the workflow and the ref (or if you were doing a deployment, the deployment environment would show here). + +We want to customize this where that our cloud provider (Azure in my example) can authenticate with our approved reusable workflow. + +> For AWS, the [docs](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services) say: "Note: Support for custom claims for OIDC is unavailable in AWS." This is saying you can't create any custom claims ([discussed here](https://github.com/aws-actions/configure-aws-credentials/issues/306)), but you can still customize the subject (`sub`) claim as I show later in this post. AWS's [docs](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html#idp_oidc_Create_GitHub) and [section in the `aws/configure-aws-credentials` action](https://github.com/aws-actions/configure-aws-credentials?tab=readme-ov-file#claims-and-scoping-permissions) has more information on this. +{: .prompt-info } + +## Customizing the Subject Claim in GitHub + +We can [customize the subject claim using the API](https://docs.github.com/en/rest/actions/oidc?apiVersion=2022-11-28#set-the-customization-template-for-an-oidc-subject-claim-for-a-repository), but more easily, we can use [@tspascoal](https://github.com/tspascoal)'s [gh-oidc-sub](https://github.com/tspascoal/gh-oidc-sub) `gh` CLI extension: + +1. Install the `gh` CLI extension: + ```bash + gh extensions install tspascoal/gh-oidc-sub + ``` + {: .nolineno} +2. Let's verify the existing claims (if it is customized or using default): + ```bash + gh oidc-sub get --repo joshjohanning-org/oidc-claims-demo + ``` + {: .nolineno} +3. If nothing has been changed yet at the repo or org level, it should look like this: + ```json + { + "use_default": true + } + ``` + {: .nolineno} + > Note that if it isn't using the default, you can set it back to the default by running: + > + > ```bash + > gh oidc-sub usedefault --repo joshjohanning-org/oidc-claims-demo + > ``` + > {: .nolineno} + {: .prompt-tip } +4. Then, we can run the following command to customize the subject claim to include the `job_workflow_ref`: + ```bash + gh oidc-sub set --repo joshjohanning-org/oidc-claims-demo --subs "job_workflow_ref" + ``` + {: .nolineno} +5. This just returns `{}`, but let's run the `get` command again to verify that it was set: + ```bash + gh oidc-sub get --repo joshjohanning-org/oidc-claims-demo + ``` + {: .nolineno} +6. Now, the output should look like this: + ```json + { + "use_default": false, + "include_claim_keys": [ + "job_workflow_ref" + ] + } + ``` + {: .nolineno} +7. If we run the step to print out the OIDC token claims as discussed in the [section above](#the-oidc-subject-claim), we will see: + ```json + "sub": "job_workflow_ref:joshjohanning-org/oidc-claims-demo/.github/workflows/azure-oidc-demo.yml@refs/heads/main" + ``` + {: .nolineno} +8. With the subject claim customized, we can require all interactions with Azure use this reusable workflow 🎉 + +## Using the Subject in Azure + +Now that we have the subject claim customized on the GitHub repository, we can use it with the federated credential on the Azure side. + +1. In AAD (Entra ID), navigate to the app registration that you want to use to authenticate to Azure +2. Under "Certificates & secrets", add a new "Federated credential" +3. You can select "GitHub" as the federated credential scenario, but it's easier to just use "Other issuer" +4. For the issuer, use: `https://token.actions.githubusercontent.com` +5. For the subject identifier, use something like: + ``` + job_workflow_ref:joshjohanning-org/reusable-workflows/.github/workflows/azure-oidc-sample.yml@refs/tags/v1 + ``` + {: .nolineno} + > You will have to decide if you want to use a tag or a branch for the ref, and in Azure, you can't use wildcards (in AWS you can!). I prefer tags for consistency, but you can use a branch if you simply want your users to refer to `@main` to always have the latest. If referencing a branch, use: `refs/heads/main` + {: .prompt-info } +6. It should look something like this: + ![Federated credential in Azure using job_workflow_ref](azure-oidc-light.png){: .shadow }{: .light } + ![Federated credential in Azure using job_workflow_ref](azure-oidc-dark.png){: .shadow }{: .dark } + _Federated credential in Azure using job_workflow_ref_ + +> Note the maximum number of federated credentials per app registration from the [Azure docs](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust-user-assigned-managed-identity?pivots=identity-wif-mi-methods-azp#important-considerations-and-restrictions): +> > A maximum of 20 federated identity credentials can be added to an application or user-assigned managed identity. +{: .prompt-info } + +## Configuring the Reusable Workflow + +So far we have updated the subject claim in GitHub and configured the federated credential in Azure. Now, we will create a reusable workflow in GitHub and test out if we can 1) successfully authenticate using the approved `@v1` tag above, and 2) if it fails when it should when using another tag or any other reusable workflow. + +Here's my calling workflow (i.e.: the workflow in my "app" repo): + +```yml +name: Azure OIDC Demo + +on: + push: + branches: main + pull_request: + branches: main + workflow_dispatch: + +jobs: + azure: + uses: joshjohanning-org/reusable-workflows/.github/workflows/azure-oidc-sample.yml@v1 # v1 is 'approved' workflow + with: + keyvault: josh-key-vault-test +``` +{: file='.github/workflows/azure-oidc-demo.yml'} + +> For [security purposes](https://github.blog/changelog/2023-06-15-github-actions-securing-openid-connect-oidc-token-permissions-in-reusable-workflows/), if you need to fetch an OIDC token generated within a reusable (called) workflow that is outside your enterprise/organization, the `id-token: write` needs to be explicitly set at the caller workflow level or in the specific job that calls the reusable workflow. +{: .prompt-tip } + +And here's the called workflow (i.e.: the workflow in my "reusable workflows" repo): + +{% raw %} +```yml +name: azure-oidc-sample + +on: + workflow_call: + keyvault: + description: name of the keyvault + type: string + default: josh-key-vault-test + +jobs: + login: + runs-on: ${{ inputs.runs-on }} + permissions: + id-token: write # this is needed for oidc + contents: read # this is needed to clone repo + + steps: + - uses: actions/checkout@v4 + # logging in with OIDC + - name: 'Az CLI login' + uses: azure/login@v1 + with: + client-id: d951ac80-75f2-446a-aca6-cd53a68611f0 + tenant-id: e9846558-c4f0-4312-a89e-ebebe80779a1 + subscription-id: 2e9bfb26-ca29-44f5-8920-72c1b0b37188 + + - name: print azure subscription info + run: | + az account show + az account show | jq ".id" + + - name: get all az keyvault secrets + run: | + for secret_name in $(az keyvault secret list --vault-name ${{ inputs.keyvault }} --query "[].{name:name}" --output tsv); do + secret_value=$(az keyvault secret show --vault-name "${{ inputs.keyvault }}" --name $secret_name --query value -o tsv) + echo "::add-mask::$secret_value" + echo "$secret_name=$secret_value" >> $GITHUB_ENV + done + + - name: testing secrets + run: | + echo "echoing as secret: ${{ secrets.my-secret }}" # doesn't work + echo "echoing as env: ${{ env.my-secret }}" # works +``` +{: file='.github/workflows/azure-oidc-sample.yml'} +{% endraw %} + +When running the workflow, we can see that it successfully logs in and fetches the secrets from the keyvault: + +![Using OIDC in GitHub Actions to authenticate to Azure and retrieve secrets from a Key Vault](github-oidc-success-light.png){: .shadow }{: .light } +![Using OIDC in GitHub Actions to authenticate to Azure and retrieve secrets from a Key Vault](github-oidc-success-dark.png){: .shadow }{: .dark } +_Using OIDC in GitHub Actions to authenticate to Azure and retrieve secrets from a Key Vault_ + +If we try to be sneaky and use a different reusable workflow, a different tag/branch, or no reusable workflow at all, it will fail. + +Here's an example where I tried to use a different reusable workflow and it fails: + +```yml +jobs: + azure: + uses: joshjohanning-org/reusable-workflows/.github/workflows/azure-oidc-sample-not-approved.yml@oidc-sample-not-approved # v1 is 'approved' workflow + with: + keyvault: josh-key-vault-test +``` +{: file='.github/workflows/azure-oidc-demo.yml'} + +![Failing to use OIDC to authenticate to Azure because I'm not using an approved reusable workflow](github-oidc-failure-light.png){: .shadow }{: .light } +![Failing to use OIDC to authenticate to Azure because I'm not using an approved reusable workflow](github-oidc-failure-dark.png){: .shadow }{: .dark } +_Failing to use OIDC to authenticate to Azure because I'm not using an approved reusable workflow_ + +## Summary + +Often, I see that [teams want to abstract and isolate their reusable workflows](https://github.com/orgs/community/discussions/17554) completely from the teams calling them. The team building the reusable workflows don't want to require secrets to be stored in the *calling* repository for the sake of both reducing complexity and increasing security. There is the `secrets: inherit` keyword that can be used to pass in all secrets and satisfy the complexity complain, but it doesn't satisfy the security concern. Any repo-level or organization-level secret that exists in the repository can be accessed by anyone with `write` permissions to the repo by creating a new workflow. There is a [roadmap item](https://github.com/github/roadmap/issues/636) to address these concerns, but there is no timeline for it yet. + +However, using OIDC to authenticate to Azure and retrieve secrets from a Key Vault is a great way to solve this problem, especially if you're already using an external key store like Azure Key Vault to manage your secrets. Where it really gets magically is when we combine OIDC and reusable workflows to create a secure and consistent reusable workflow that can be used across the organization without having to make secrets accessible to any other workflow. ✨ diff --git a/_posts/2023-12-22-github-web-editor-multiline-comment.md b/_posts/2023-12-22-github-web-editor-multiline-comment.md new file mode 100644 index 0000000..5da4653 --- /dev/null +++ b/_posts/2023-12-22-github-web-editor-multiline-comment.md @@ -0,0 +1,26 @@ +--- +title: 'Creating a Multiline Comment in the GitHub Web Editor' +author: Josh Johanning +date: 2023-12-22 11:40:00 -0600 +description: Adding a multiline comment in the GitHub Web Editor; for example when editing a GitHub Actions workflow file +categories: [GitHub, Commits] +tags: [GitHub, Commits] +media_subpath: /assets/screenshots/2023-12-22-github-web-editor-multiline-comment +image: + path: multiline-comment.gif + width: 100% + height: 100% + alt: Creating a multiline comment in the GitHub Web Editor +--- + +## Summary + +I'm embarrassed to say that every time I was editing a GitHub Actions workflow file with the web editor in GitHub, if I needed to make a multiline comment, I either added each `#` in manually, cloned the repo, or opening the repo with the [github.dev web-based editor](https://docs.github.com/en/codespaces/the-githubdev-web-based-editor) with the `.` shortcut. I knew how to create a multiline comment in VS Code (`Cmd ⌘` + `k`, `Cmd ⌘` + `c`), but I didn't know how to do it in the web editor or if it was even possible. + +Well, TIL! + +## The Shortcut + +That shortcut is simply `Cmd ⌘` + `/` (or `Ctrl` + `/` on Windows). It's that easy! This comments and uncomments the selected lines. + +It seems as if `Cmd ⌘` + `/` is a standard keyboard shortcut for commenting/uncommenting in many editors, including VS Code, Atom, and Sublime Text. I'm probably just used to `Cmd ⌘` + `k`, `Cmd ⌘` + `c` from my Visual Studio days! diff --git a/_posts/2024-01-08-github-script-to-add-dependabot-file.md b/_posts/2024-01-08-github-script-to-add-dependabot-file.md new file mode 100644 index 0000000..4134386 --- /dev/null +++ b/_posts/2024-01-08-github-script-to-add-dependabot-file.md @@ -0,0 +1,87 @@ +--- +title: 'GitHub: Script to Add dependabot.yml to a List of Repos' +author: Josh Johanning +date: 2024-01-08 12:30:00 -0600 +description: Add the dependabot.yml file programmatically to a list of GitHub repositories +categories: [GitHub, Scripts] +tags: [GitHub, Scripts, Octokit, Dependabot] +media_subpath: /assets/screenshots/2024-01-08-github-script-to-add-dependabot-file +image: + path: dependabot-enable.png + width: 100% + height: 100% + alt: Dependabot configuration for a repository +--- + +## Overview + +I've been exploring how to enable [Dependabot Version Updates](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/about-dependabot-version-updates) across a large set of repositories. Unlike Dependabot Security Alerts or Dependabot Security Updates, Dependabot Version Updates relies on a file existing in the repository: `.github/dependabot.yml`{: .filepath}. Confusingly, there is an "**Enable**" button when configuring Dependabot Version Updates, but that only is a link to be able to create and commit the file manually into the repository. + +What I wanted to do was to be able to add the `.github/dependabot.yml`{: .filepath} file to a list of repositories programmatically. I was able to do this using the [Octokit](https://octokit.github.io/rest.js/v18) library and the [GitHub API](https://docs.github.com/en/rest/reference/repos#create-or-update-file-contents). Thankfully, adding or updating a single file in a repository is easy; adding multiple files as part of the same commit is slightly harder with the GitHub API but still doable (have to use the [Git trees API](https://docs.github.com/en/rest/git/trees?apiVersion=2022-11-28); example [here](https://github.com/joshjohanning-org/commit-sign-app/blob/f010f5d8f86655b55166142bf322d5d1b6945b1a/.github/workflows/commit-sign.yml#L72-L121)!). + +## The Scripts + +These scripts are in my [github-misc-scripts](https://github.com/joshjohanning/github-misc-scripts) repo: + +- [`generate-repositories-list.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/generate-repositories-list.sh) +- [`add-dependabot-file-to-repositories.js`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/add-dependabot-file-to-repositories.js) + +## Using the Scripts + +### Prerequisites + +- Node.js installed +- Environment variable named `GITHUB_TOKEN` with a GitHub PAT that has `repo` scope (for committing) +- Dependencies installed via `npm i octokit fs` +- Update the `gitUsername`, `gitEmail`, and `overwrite` const at the top of the script accordingly + - If you want to use a GitHub App to be the commit author: + - `gitUsername` value: + - Should be the GitHub App name with `[bot]` appended + - Example: `josh-issueops-bot[bot]` + - `gitEmail` value: + - Return the user ID with: `gh api '/users/josh-issueops-bot[bot]' --jq .id` + - The email will then be: `149130343+josh-issueops-bot[bot]@users.noreply.github.com` + +### Usage + +1. Prepare a list of repositories that you want to add the `dependabot.yml`{: .filepath} file to and place in a file, one per line + - You can use the [`generate-repositories-list.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/generate-repositories-list.sh) script to generate a list of repos in a GitHub org, and then modify accordingly: + ```bash + ./generate-repositories-list.sh joshjohanning-org > repos.txt + ``` + {: .nolineno} + - Or, create your own input file with the list of repos you want to add the `dependabot.yml`{: .filepath}, one per line: + ```sh + org/repo1 + org/repo2 + ``` + {: file='repos.txt'} + {: .nolineno} +2. From there, it's pretty simple - run the script, passing in the `repos.txt`{: .filepath} file: + ```bash + export GITHUB_TOKEN=ghp_abc + npm i octokit fs papaparse + node ./add-dependabot-file-to-repositories.js ./repos.txt ./dependabot.yml + ``` + {: .nolineno} + +## Future Enhancements + +- [ ] Add an option to create a pull request instead of committing directly to the default branch + +## Edit: More feature-rich alternative + +[@ruzickap pointed out](https://github.com/joshjohanning/joshjohanning.github.io/issues/33#issuecomment-1896339564) that we can also use [`multi-gitter`](https://github.com/lindell/multi-gitter/) to run a script against a set of repositories. This tool already creates pull requests for us, as well as includes a command to track the `status` of the PRs, to `merge` the PRs, and to `close` the PRs ✨. + +See my [follow-up comment](https://github.com/joshjohanning/joshjohanning.github.io/issues/33#issuecomment-1951356259) for an example on using [`multi-gitter`](https://github.com/lindell/multi-gitter/) to copy in a `dependabot.yml`{: .filepath} file if it doesn't exist. + +There's also a more complex example in my [comment](https://github.com/joshjohanning/joshjohanning.github.io/issues/33#issuecomment-1951356259) that creates a `dependabot.yml`{: .filepath} file if it doesn't exist, but if it does exist, only check to see if there is a `package-ecosystem: github-actions` section and if not, add it. + +## Summary + +This will speed up the process of adding the `dependabot.yml`{: .filepath} file to a list of repositories. This can be helpful to make sure teams are keeping up to date on their dependencies. I use this all the time especially to keep up with marketplace and internal GitHub Actions that my repositories are referencing. Feel free to let me know if I'm missing anything and/or submit a PR to enhance this further! 🚀 + +> See my other Dependabot Version Updates posts: +> - [Configure GitHub Dependabot to Keep Actions Up to Date](/posts/github-dependabot-for-actions/) +> - [Configuring Dependabot for Reusable Workflows in GitHub](/posts/dependabot-reusable-workflows/) +{: .prompt-info } diff --git a/_posts/2024-02-03-github-script-to-add-users-to-project.md b/_posts/2024-02-03-github-script-to-add-users-to-project.md new file mode 100644 index 0000000..e52dc53 --- /dev/null +++ b/_posts/2024-02-03-github-script-to-add-users-to-project.md @@ -0,0 +1,53 @@ +--- +title: 'GitHub: Script to Add Users to a Project' +author: Josh Johanning +date: 2024-02-03 7:00:00 -0600 +description: Add users to a GitHub ProjectV2 programmatically +categories: [GitHub, Scripts] +tags: [GitHub, Scripts, GitHub Projects] +media_subpath: /assets/screenshots/2024-02-03-github-script-to-add-users-to-project +image: + path: github-project.png + width: 100% + height: 100% + alt: An organization Project in GitHub +--- + +## Overview + +We're often adding new users to [Projects (ProjectsV2) in GitHub](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects), and previously, it was all manual work in the UI. I wanted to automate this, but found the [`updateProjectV2Collaborators`](https://docs.github.com/en/graphql/reference/mutations#updateprojectv2collaborators) mutation a little tricky to figure out. After some trial and error, I was able to get it working and wanted to share the script I created to add users to a GitHub Project. + +## The Script + +The script is in my [github-misc-scripts](https://github.com/joshjohanning/github-misc-scripts) repo: + +- [`add-user-to-project.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/add-user-to-project.sh) + +## Using the Script + +### Usage + +```bash +Usage: ./add-user-to-project.sh +``` + +Example: + +```bash +./add-user-to-project.sh joshjohanning-org my-repo 1234 joshjohanning ADMIN +``` + +Notes: + +- You need the `project` scope, e.g.: `gh auth login -s project` +- Role options: `ADMIN`, `WRITER`, `READER`, `NONE` + +## Summary + +Hopefully this speeds up your adding of users to an Organization Project in GitHub! 🚀 + +> See my other ProjectsV2 scripts: +> +> - [`get-projects-in-organization.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-projects-in-organization.sh) +> - [`get-projects-added-to-repository.sh`{: .filepath}](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-projects-added-to-repository.sh) +{: .prompt-info } diff --git a/_posts/2024-03-21-github-packages-migrate-docker-containers.md b/_posts/2024-03-21-github-packages-migrate-docker-containers.md new file mode 100644 index 0000000..c884184 --- /dev/null +++ b/_posts/2024-03-21-github-packages-migrate-docker-containers.md @@ -0,0 +1,92 @@ +--- +title: 'GitHub Packages: Migrate Docker Containers Between GitHub Instances' +author: Josh Johanning +date: 2024-03-21 13:30:00 -0500 +description: Migrating Docker containers stored in GitHub Packages / GitHub Container Registry between GitHub instances +categories: [GitHub, Packages] +tags: [GitHub, Scripts, GitHub Packages, gh cli, Docker, Containers, Migrations] +media_subpath: /assets/screenshots/2024-03-13-github-packages-migrate-docker-containers +image: + path: docker-container-github-packages-light.png + width: 100% + height: 100% + alt: Docker Containers in GitHub Packages +--- + +## Overview + +I have been working with more customers who are migrating GitHub instances and want to be able to migrate GitHub Packages. There is not an easy lift-and-shift approach to migrate GitHub Packages between instances; each ecosystem is independent. To go along with [my other solutions](/categories/packages/), I also scripted out the Docker container migration. Take a look and let me know what you think! + +> See my other GitHub Package --> GitHub Package migration posts: +> +> - [Migrate NuGet Packages Between GitHub Instances](/posts/github-packages-migrate-nuget-packages/) +> - [Migrate npm Packages Between GitHub Instances](/posts/github-packages-migrate-npm-packages/) +> - [Migrate Maven Packages Between GitHub Instances](/posts/github-packages-migrate-maven-packages/) +{: .prompt-info } + +## The script + +The script can be found in my [github-misc-scripts](/posts/github-misc-scripts/) repo here: + +- **[https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/migrate-docker-containers-between-github-instances.sh](https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/migrate-docker-containers-between-github-instances.sh)** + +## Running the script + +### Prerequisites + +1. [`gh cli`](https://cli.github.com) installed +2. Set the source GitHub PAT env var: `export GH_SOURCE_PAT=ghp_abc` (must have at least `read:packages`, `read:org` scope) +3. Set the target GitHub PAT env var: `export GH_TARGET_PAT=ghp_xyz` (must have at least `write:packages`, `read:org` scope) + +### Usage + +You can call the script via: + +```bash +./migrate-docker-containers-between-github-instances.sh \ + \ + \ + \ + \ + +``` + +### Example + +An example of this in practice: + +```bash +export GH_SOURCE_PAT=ghp_abc +export GH_TARGET_PAT=ghp_xyz + +./migrate-docker-containers-between-github-instances.sh \ + joshjohanning-org \ + github.com \ + joshjohanning-org-packages \ + github.com \ + true +``` + +## Notes + +- If mapping to repositories with the 5th input parameter ``: + - This script assumes that the target org's repo name is the same as the source + - If the repo doesn't exist, the package will still import but won't be mapped to a repo +- Otherwise, images can be linked to repositories manually afterwords +- The packages API doesn't appear to pull all packages? I am unsure if it is because some of my packages are the same SHA behind the scenes and just published under different names, but double check that all Docker containers are migrated +- Image signatures are not migrated (see improvement ideas below) +- To clean up ALL local images, use this one-liner: + ```bash + rmi -f $(docker images -q) + ``` + {: .nolineno} + +## Improvement Ideas + +- [ ] Use [ORAS CLI](https://oras.land/docs/commands/use_oras_cli) to migrate (might help with image signatures) +- [ ] Figure out why not all `container` type packages are not being returned by the API +- [ ] Map between repositories where the target repo is named differently than the source repo (likely this isn't needed since if repo doesn't exist, packages will still be pushed, the package just won't be linked to a repository) + +## Summary + +Drop a comment here or an issue or PR on my [github-misc-scripts repo](https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/migrate-docker-containers-between-github-instances.sh) if you have any feedback or suggestions! Happy container migrating! 📦 🐳 diff --git a/_posts/2024-03-23-github-action-for-twistlock-results.md b/_posts/2024-03-23-github-action-for-twistlock-results.md new file mode 100644 index 0000000..c68d9e5 --- /dev/null +++ b/_posts/2024-03-23-github-action-for-twistlock-results.md @@ -0,0 +1,103 @@ +--- +title: 'GitHub Action for adding Twistlock Scan Results to job summary' +author: Josh Johanning +date: 2024-03-23 17:30:00 -0500 +description: GitHub Action to convert Twistlock's JSON scan results to markdown to add to the job summary +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, Octokit] +media_subpath: /assets/screenshots/2024-03-23-github-action-for-twistlock-results +image: + path: twistlock-job-summary-light.png + width: 100% + height: 100% + alt: Twistlock Scan Results in GitHub Job Summary +--- + +## Overview + +I was working with a customer recently who was using Twistlock / Prisma Cloud Scan to scan their Docker containers. Some CLI tools, like [Checkmarx's](https://checkmarx.com/resource/documents/en/34965-68643-scan.html#UUID-a0bb20d5-5182-3fb4-3da0-0e263344ffe7_section-idm4631465209593633552409907579) `cx scan create --report-format markdown`, allow you to output the scan results in a markdown format natively. This is useful for adding the scan results to the job summary in GitHub Actions or even posting as a comment on a PR. + +The [Twistlock CLI](https://pan.dev/prisma-cloud/docs/twistcli_gs/), however, does not have this feature. We can save the scan results as a JSON file, but we have to convert it to markdown ourselves. Luckily, there's a npm package [`json2md`](https://www.npmjs.com/package/json2md) that will do most of the heavy lifting! I created a custom type function using `json2md` to convert the specialized JSON that the `twistcli scan --output-file scan-results.json` command creates to an easy-to-read markdown table. + +## The Action + +To make it easier to use this in GitHub Actions, I wrapped this in an [Action published to the marketplace](https://github.com/marketplace/actions/twistlock-prisma-scan-results-json-to-markdown). It takes the JSON scan result file as an input. Then, the action creates two markdown tables: + +1. A table with high-level summary of the scan information, link to results, and sum of vulnerabilities by severity +2. A table with detailed information on each vulnerability found in the scan + +The action has two outputs: + +1. `summary-table`: File location to the summary table +2. `vulnerability-table`: File location to the vulnerability table + +You can then use these outputs in a subsequent step to add the tables to the job summary or post as a comment on a PR. + +## Usage + +The sample below shows how to add the generated markdown tables to the job summary. + +{% raw %} +```yml +steps: + - run: twistcli scan --output-file scan-results.json + - name: convert-twistlock-json-results-to-markdown + id: convert-twistlock-results + uses: joshjohanning/twistlock-results-json-to-markdown-action@v1 + with: + results-json-path: scanresults.json + - name: write to job summary + run: | + cat ${{ steps.convert-twistlock-results.outputs.summary-table }} >> $GITHUB_STEP_SUMMARY + cat ${{ steps.convert-twistlock-results.outputs.vulnerability-table }} >> $GITHUB_STEP_SUMMARY +``` + +This shows up in the job summary like this: +![Twistlock scan results in the Actions job summary](twistlock-job-summary-light.png){: .shadow }{: .light } +![Twistlock scan results in the Actions job summary](twistlock-job-summary-dark.png){: .shadow }{: .dark } +_Twistlock scan results in the Actions job summary_ + +## Adding as a PR Comment + +I really like using the [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) action to post comments on PRs: + +> Create a comment on a pull request, if it exists update that comment. + +To instead post the summary table as a comment in a pull request, you can use the following: + +```yml +steps: + - run: twistcli scan --output-file scan-results.json + - name: convert-twistlock-json-results-to-markdown + id: convert-twistlock-results + uses: joshjohanning/twistlock-results-json-to-markdown-action@v1 + with: + results-json-path: scanresults.json + - uses: marocchino/sticky-pull-request-comment@v2 + if: github.event_name == 'pull_request' + with: + path: ${{ steps.convert-twistlock-results.outputs.summary-table }} +``` + +You could alternatively use the [`gh cli`](https://cli.github.com/manual/gh_pr_comment) instead of a marketplace action to post the comment (though this won't update the existing comment, it will create a new comment every time the job is triggered in a PR). + +```yml +steps: + - run: twistcli scan --output-file scan-results.json + - name: convert-twistlock-json-results-to-markdown + id: convert-twistlock-results + uses: joshjohanning/twistlock-results-json-to-markdown-action@v1 + with: + results-json-path: scanresults.json + - name: create pr comment + if: github.event_name == 'pull_request' + run: | + gh pr comment ${{ github.event.number }} \ + -R ${{ github.repository }}\ + -F ${{ steps.convert-twistlock-results.outputs.summary-table }} +``` +{% endraw %} + +## Summary + +Drop a comment here or an issue or PR on my [repo](https://github.com/joshjohanning/twistlock-results-json-to-markdown-action) if you have any feedback or suggestions! Happy security scanning! 🛡️ diff --git a/_posts/2024-03-26-github-actions-dynamic-matrix.md b/_posts/2024-03-26-github-actions-dynamic-matrix.md new file mode 100644 index 0000000..68c89bc --- /dev/null +++ b/_posts/2024-03-26-github-actions-dynamic-matrix.md @@ -0,0 +1,132 @@ +--- +title: 'GitHub Actions: Building a Dynamic Matrix' +author: Josh Johanning +date: 2024-03-26 16:00:00 -0500 +description: Building matrices dynamically in GitHub Actions +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, Matrix] +media_subpath: /assets/screenshots/2024-03-26-github-actions-dynamic-matrix +image: + path: dynamic-matrix-light.png + width: 100% + height: 100% + alt: Building a dynamic matrix in GitHub Actions +--- + +## Overview + +{% raw %} +Sometimes when you're creating GitHub Actions, you have to get creative to get the job done. One such example is when you need to run several jobs at the same time, but the number of jobs and inputs are variable. If I were using Azure DevOps, I would use the [`${{ each }}` syntax in the YML](https://github.com/joshjohanning/pipeline-templates/blob/main/dotnet-core-web/dotnet-core-deploy.yml#L7) to run the job multiple times with different inputs. GitHub Actions wasn't implemented with this type YAML syntax (loops or anchors), but we can use a dynamic matrix to accomplish something similar. Using [`if:` conditionals on jobs](https://docs.github.com/en/actions/using-jobs/using-conditions-to-control-job-execution) could work too, but then they show up as skipped in the Actions UI. + +Instead, we can build a matrix dynamically. Let me show you how! + +> Full disclosure, I'm piggy-backing on this post from my co-worker [@kenmuse](https://github.com/kenmuse), "[Dynamic Build Matrices in GitHub Actions](https://www.kenmuse.com/blog/dynamic-build-matrices-in-github-actions/). If you're interested in this topic, check out his post too! +{: .prompt-info } + +## Example + +In my example, I was tying this to an IssueOps migration approach. I had an [issue template](https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository) that asked for a [list of repositories](https://github.com/joshjohanning-org/dynamic-matrix-example/issues/new?template=repos.yml). I wanted to dynamically run a job for each repository in the list. + +To prepare the workflow, I parse the issue body with the [stefanbuck/github-issue-parser](https://github.com/stefanbuck/github-issue-parser) action. This action will convert my input into a predefined variable as opposed to having to write something to parse the issue template myself. + +Then, I write some JavaScript to convert the list of repositories into a valid JSON object. Finally, in another job, I can then use the JSON job output of the previous job to create the dynamic matrix. + +## Workflow + +```yml +name: dynamic-matrix +on: + issue_comment: + types: [created] + +jobs: + prepare: + runs-on: ubuntu-latest + if: contains(github.event.comment.body, '/run-automation') + outputs: + repositories: ${{ steps.json.outputs.repositories }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Parse issue body + id: parse-issue-body + uses: stefanbuck/github-issue-parser@v3 + + - run: echo $JSON_STRING + env: + JSON_STRING: ${{ steps.parse-issue-body.outputs.jsonString }} + + - name: Build matrix + uses: actions/github-script@v7 + id: json + with: + script: | + let repositories = process.env.REPOSITORIES.replace(/\r/g, '').split('\n'); + let json = JSON.stringify(repositories); + console.log(json); + core.setOutput('repositories', json); + env: + REPOSITORIES: ${{ steps.parse-issue-body.outputs.issueparser_repositories }} + + run-matrix: + needs: prepare + runs-on: ubuntu-latest + strategy: + matrix: + repository: ${{ fromJson(needs.prepare.outputs.repositories) }} + fail-fast: false + max-parallel: 15 + steps: + - run: echo "${{ matrix.repository }}" # this will print the repo name +``` + +This will render the following workflow: +![Building a Dynamic Matrix in GitHub Actions](dynamic-matrix-light.png){: .shadow }{: .light } +![Building a Dynamic Matrix in GitHub Actions](dynamic-matrix-dark.png){: .shadow }{: .dark } +_Building a Dynamic Matrix in GitHub Actions_ + +## Another Workflow Example + +Here's another example that I'm sharing here for reference. I thought this one was interesting because: + +1. I was building my own JSON object manually (see lines 14-15 below) +2. Note the difference with the `matrix:` line (line 22) compared to lines 40-41 in the [example above](#example). Since I am already defining `repository` in the JSON array I'm building manually, I don't need to define my own object for the matrix. + +```yml +name: workflow-dispatch-dynamic-matrix +on: + workflow_dispatch: + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + repository: ${{ steps.json.outputs.repository }} + steps: + - name: build matrix + id: json + run: | + repository='{ "repository": ["repo1","repo2","repo3","repo4"] }' + echo "repository=$repository" >> "$GITHUB_OUTPUT" + + run-matrix: + needs: prepare + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.prepare.outputs.repository) }} + steps: + - run: echo "${{ matrix.repository }}" +``` +{% endraw %} + +## Summary + +I have built this out for a few customers now, so I wanted to finally capture this in a blog post. This can be really useful for dynamically running jobs in GitHub Actions as opposed to using [`if:` conditions to not run jobs](https://docs.github.com/en/actions/using-jobs/using-conditions-to-control-job-execution). The problem with the `if:` condition is that it will still show the job as skipped in the Actions visualization. + +Another example I have built recently was using a dynamic matrix to run a trigger N number of workflows in other repositories managed via a JSON input file stored centrally (this I will have to write as a separate blog post in itself!). + +There are times where this is a no-brainer solution, but I have also seen others try to shoe-horn this into a solution where they are trying to run non-similar jobs in a matrix. This is not the intended use of a matrix I would say. Building out a dynamic matrix is a great solution for running similar jobs and the only thing that changes between the jobs is some input parameters. + +I hope this helps you in your GitHub Actions journey! 🚀 diff --git a/_posts/2024-05-20-github-actions-docker-actions-private-registry.md b/_posts/2024-05-20-github-actions-docker-actions-private-registry.md new file mode 100644 index 0000000..5901db9 --- /dev/null +++ b/_posts/2024-05-20-github-actions-docker-actions-private-registry.md @@ -0,0 +1,265 @@ +--- +title: 'GitHub Actions: Create a Docker Container Action Hosted in a Private Image Registry' +author: Josh Johanning +date: 2024-05-20 20:30:00 -0500 +description: Create a Docker container action that is hosted in a private image registry +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, Docker] +media_subpath: /assets/screenshots/2024-05-20-github-actions-docker-actions-private-registry +image: + path: docker-action-composite-action.png + width: 100% + height: 100% + alt: Using a Docker container action that's hosted in a private image registry +--- + +## Overview + +Docker container actions can be awesome! They very neatly encapsulate an entire step's logic into a container image so that no matter what host the Actions runner is running on[^footnote-1], you get the same result. There are a few popular actions in the marketplace that are Docker container actions, such as the [SonarQube Scan action](https://github.com/SonarSource/sonarqube-scan-action) and the [Super-Linter Action](https://github.com/super-linter/super-linter). You can easily tell that these are Docker container actions because their `action.yml`{: .filepath} file has a `using: 'docker'` line in it. You can find other examples of Docker container actions by using [GitHub's search](https://github.com/search?q=%22using%3A+docker%22+language%3AYML+path%3A**%2Faction.yml&type=code). + +Sometimes, it makes a lot of sense to use Docker container actions, especially in the SonarQube and Super-Linter action examples above. These actions rely on source files / binaries to exist to run the scan or linting. However, there are a fair share of Docker container actions on the marketplace that didn't really need to be Docker container actions. Part of this is likely due that JavaScript and Docker actions were the only type of action originally (Actions was launched in 2018). Composite actions came around [originally in August 2020](https://github.com/actions/runner/issues/646) and were limited to only using `run:` command steps. + +However, sometimes I don't think it makes sense to create a Docker action. I don't necessarily like when I see simple actions calling a REST API endpoint run as a Docker action due to some of the [limitations](#fn:footnote-1) and theoretical overhead of running a Docker container. + +With that caveat out of the way, let's dive into creating Docker container actions, as well as how to use a private image registry to host the Docker image. + +> Container jobs are different than Docker actions! If you are using a container job, there are ways to natively provide authentication. See [my post on container jobs](/posts/github-container-jobs) for more information! +{: .prompt-info } + +## Docker Action Hosting Options + +When [creating a Docker action](https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action), you can either specify a local `Dockerfile`{: .filepath} (that means this has to be built every time the Action runs) or you can specify a **public** Docker registry like Docker Hub or GitHub Container Registry. The [SonarQube Scan action uses a local `Dockerfile`{: .filepath}](https://github.com/SonarSource/sonarqube-scan-action/blob/d3ca1743de4293fc030a2e1ded1fb44088b80b76/action.yml#L9). Conversely, the [SILE Typesetter action uses a public GitHub Container Registry image](https://github.com/sile-typesetter/sile/blob/b2cc0841ff603abc335c5e66d8cc3c64b65365eb/action.yml#L10). + +You'll note the key word here: **public**. If you want to use a private image registry, you'll need to authenticate to that registry. This is where things get a bit more complicated. If you pay attention to the workflow's logs, you'll notice that the Docker container actions are pulled or built as the first step of the job, in the initialization step. This runs before any of the steps in the job run. This is important to note because of course, you typically need to authenticate to a private image registry. But how can we provide authentication if we can't otherwise run a step before the initialization step? + +I have been asked this by a few customers as well as [provided steps to implement this in a GitHub Discussion thread](https://github.com/orgs/community/discussions/45981), so I wanted to capture this in a blog post so that others can benefit from this knowledge. + +## Connecting to a Private Image Registry + +Somehow, we need to inject authentication before the job runs, or at least be able to dynamically provide credentials to the private image registry. There are a few ways to do this: + +1. If you are running this in a self-hosted environment, you could pre-bake the Docker credentials onto the self-hosted runner host. This is not ideal because this could be a security risk if the runner host is compromised and sort of defeats the purpose of completely portal and ephemeral runner environments. +2. You could add a runner [pre-job step](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/running-scripts-before-or-after-a-job). This would only work on self-hosted runners, and the authentication mechanism between your runner and the secret store would still provide a challenge. +3. You could [customize with the container commands](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/customizing-the-containers-used-by-jobs#container-customization-commands) by using the [Runner Container Hooks](https://github.com/actions/runner-container-hooks). This also only works for self-hosted runners and has a moderate amount of setup required. +4. Use a composite action to wrap/hide and reference the Docker action as a [local action reference](https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsuses). The local action then references the real Docker container action that needs authentication to the private image registry. This would work with GitHub-hosted runners and self-hosted runners! + +I'll be going over the later two options in detail in the next sections. + +### Using the Container Customization Commands + +The [container customization commands](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/customizing-the-containers-used-by-jobs#container-customization-commands) are really powerful, and if we're using self-hosted runner infrastructure, this can be a great way to inject credentials into the runner environment. Specifically, we can use the [`run_container_step`](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/customizing-the-containers-used-by-jobs#run_container_step) hook that is called once for every container action in the job. This method uses [Runner Container Hooks](https://github.com/actions/runner-container-hooks). + +Here is an example of running container hooks. I am only [adding logging](https://github.com/search?q=repo%3Ajoshjohanning-org%2Factions-runner-container-hooks-tracer%20HOOK%3A&type=code) to show how the hooks are called. You would need to add the logic to authenticate to your private image registry. + +![Using container hooks to customize container commands](container-hooks-logs-example.png){: .shadow } +_Using container hooks to customize container commands_ + +To set this up, you need: + +1. Follow the [instructions in the repository](https://github.com/joshjohanning-org/actions-runner-container-hooks-tracer/blob/main/README.md#running) to set this up on your self-hosted runner + - Clone the repository to your self-hosted runner + - Run the `npm install` and `npm run build` commands in the designated folders in the repository + - Set a `.env`{: .filepath} file with the `GITHUB_ACTIONS_RUNNER_HOOKS` variable set to the absolute path of the `./packages/docker/lib/index.js`{: .filepath} file +2. The next time you run a container Action, you should see similar logs to what I have above! + +> Credits to my co-worker, [@tspascoal](https://pascoal.net/), for much of the help on this method! +{: .prompt-info } + +### Using a Composite Action and a Local Action Reference + +We can use a composite action (or, several composite actions I should say) to reference a Docker container action behind a local action reference. I admit that this is a bit of a hack, but it works, and it works with both GitHub-hosted and self-hosted runners! The idea is that at workflow compile time, Actions doesn't know about the Docker action since it is only being referenced locally from within a composite action. Therefore, it doesn't authenticate to the image registry / pull the image until the composite action is rendered and we can authenticate to the private image registry ahead of time accordingly. + +You can't simply use a composite action to reference a Docker action normally, Actions is smart enough to render the Docker action as a Docker action and attempt to pull the image before the job starts. The action referenced by the composite action must be a local action reference. Also, the local action itself cannot be a Docker action, it still needs to reference the Docker action separately (AKA, in my tests, a minimum of 2 composite actions are needed here). + +Let's set this up! In my example, I am storing a Docker action in a private Azure Container Registry. + +> Note: My [repository](https://github.com/joshjohanning-org/composite-action-private-registry-docker-actions) has 2 examples, so you'll notice that I have a subfolder `nested-composite-action-better`{: .filepath}. You don't have to do it this way; `nested-composite-action-better/action.yml`{: .filepath} could live in the root of the repo as `./action.yml`{: .filepath} The `get-path/action.yml`{: .filepath} and the `3-action-implementation/action.yml`{: .filepath} files have to live in subfolders since we can only have one `action.yml`{: .filepath} file in a directory. And technically, `get-path/action.yml`{: .filepath} could live in a separate repo altogether. +{: .prompt-info } + +Before diving into the YML, and with the note above in mind, let's take a look at the file structure of the core components of this solution as I think it will make slightly easier to understand: + +```text +joshjohanning-org/call-private-registry-docker-actions@main/ # "app" repo in this scenario +└── .github/ + └── workflows/ + └── my-awesome-workflow-testing-docker-private-action.yml + +joshjohanning-org/composite-action-private-registry-docker-actions@main/ # orchestration repo +└── nested-composite-action-better/ + ├── 1-action/ + │ └── action.yml + ├── 2-get-path/ + │ └── action.yml + └── 3-action-implementation/ + └── action.yml + +joshjohanning-org/simple-docker-action@main/ # Docker container action repo +└── private/ + └── action.yml +``` +{: .nolineno} + +I've broken up the components into three separate composite actions and the one Docker container action: + +1. The main orchestration composite action (called by `my-awesome-workflow-testing-docker-private-action.yml`{: .filepath} in the example above) +2. The get-path action that retrieves the dynamic local file path (the actions branch/tag/ref is part of the file path) (this action could live in a separate repository) +3. The local action that references the Docker container action in another repository +4. In a separate repository (requirement), the Docker container action (`joshjohanning-org/simple-docker-action/private@main`{: .filepath} in the example above) + +For workflows referencing this solution, they would reference the `1-action/action.yml`{: .filepath} action [as such](https://github.com/joshjohanning-org/call-private-registry-docker-actions/blob/main/.github/workflows/ci-7-nest-composite-action-in-remote-composite-action-better.yml): + +{% raw %} +```yml +name: ci-7-nest-composite-action-in-remote-composite-action-better +run-name: nest composite action in remote composite action better + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: simple-docker-action + uses: joshjohanning-org/composite-action-private-registry-docker-actions/nested-composite-action-better/1-action@main + with: + password: ${{ secrets.ACR_PASSWORD }} +``` +{: file='.github/workflows/my-awesome-workflow-testing-docker-private-action.yml'} + +Now, here are the rest of the workflow files as promised. + +This is the [main orchestration composite action](https://github.com/joshjohanning-org/composite-action-private-registry-docker-actions/blob/main/nested-composite-action-better/1-action/action.yml), and the one that users would reference directly in their workflows: + +```yml +name: 'private registry docker action test' +description: 'private registry docker action test' +inputs: + password: + required: true + description: acr password +runs: + using: "composite" + steps: + - name: back up docker creds + run: cp ${{ runner.workspace }}/../../.docker/config.json ${{ runner.workspace }}/../../.docker/config.json.bak + id: docker-backup + shell: bash + - name: auth to acr + run: echo ${{ inputs.password }} | docker login --username test123j --password-stdin test123j.azurecr.io + shell: bash + + # get and output path to composite action + - uses: joshjohanning-org/composite-action-private-registry-docker-actions/nested-composite-action-better/2-get-path@main + id: composite-path + + # copy composite action to tmp folder - the action-implementation folder is where you define what docker action to reference + - run: | + mkdir -p __tmp + cp -r '${{ steps.composite-path.outputs.path }}/3-action-implementation' __tmp/action-implementation + shell: bash + + # run the local composite action w/ docker private registry + - uses: ./__tmp/action-implementation + + - run: rm -rf __tmp/action-implementation + name: cleanup action + shell: bash + if: always() + + - run: mv ${{ runner.workspace }}/../../.docker/config.json.bak ${{ runner.workspace }}/../../.docker/config.json + name: restore original docker creds + shell: bash + if: always() && steps.docker-backup.conclusion == 'success' + + - run: cat ${{ runner.workspace }}/../../.docker/config.json + name: print docker registries + shell: bash + if: always() && steps.docker-backup.conclusion == 'success' +``` +{: file='nested-composite-action-better/action.yml'} + +This is an action that gets the dynamic file path to the local composite action (this action doesn't technically have to exist in the same repository, unlike the others, but it could). Since actions are cloned during the job initialization step, we can take advantage of this so that we don't have to provide any additional Git authentication to retrieve these files. Tiago [talks about this trick more in-depth here](https://pascoal.net/2023/12/14/gha-accessing-content-from-other-repos-1/)! + + +```yml +name: 'private registry docker action test - get path' +description: 'private registry docker action test - get path' +outputs: + path: + description: action path + value: ${{ steps.get-path.outputs.path }} +runs: + using: "composite" + steps: + - run: echo 'path=${{ github.action_path }}/..' >> $GITHUB_OUTPUT + id: get-path + shell: bash +``` +{: file='nested-composite-action-better/2-get-path/action.yml'} + +This is the 🪄 - the local action that then references an external Docker container action that is using a private image registry: + +```yml +name: 'private registry docker action test - call the private docker action' +description: 'private registry docker action test - call the private docker action' +runs: + using: "composite" + steps: + - uses: joshjohanning-org/simple-docker-action/private@main +``` +{: file='nested-composite-action-better/3-action-implementation/action.yml'} + +And finally, this is the [Docker action that is referencing a private container registry](https://github.com/joshjohanning-org/simple-docker-action/blob/main/private/action.yml): + +```yml +name: 'Hello World' +description: 'Greet someone and record the time' +inputs: + who-to-greet: + description: 'Who to greet' + required: true + default: 'World' +outputs: + time: + description: 'The time we greeted you' +runs: + using: 'docker' + image: 'docker://test123j.azurecr.io/actions/simple-docker-action:1' + args: + - ${{ inputs.who-to-greet }} +``` +{: file='private/action.yml'} +{% endraw %} + +For the [workflow](https://github.com/joshjohanning-org/call-private-registry-docker-actions/actions/workflows/ci-7-nest-composite-action-in-remote-composite-action-better.yml) (e.g.: app repo) calling this, this is all that they would see: + +![Using composite actions to call a docker container action hosted in a private image registry](docker-action-composite-action.png){: .shadow } +_Using composite actions to call a docker container action hosted in a private image registry_ + +😮‍💨 That's a lot of steps, I know! Ideally you can centrally store the `2-get-path/action.yml`{: .filepath}. You could also enhance this so that the `3-action-implementation/action.yml`{: .filepath} has the downstream Docker container action repository/ref dynamically injected so you don't have to create this same exact structure for every Docker action you want to reference a private image registry. You could use bash to modify the referenced action in `3-action-implementation/action.yml`{: .filepath} before it's called, thus only requiring one copy of the main orchestration bits. But the concept is at least proven out with the above example! + +> Thanks to my co-worker, [@tspascoal](https://pascoal.net/), for a ton of help on this method as well! +{: .prompt-info } + +> I do have another example of this [here](https://github.com/joshjohanning-org/composite-action-private-registry-docker-actions/blob/main/nested-composite-action/action.yml) that only uses two composite actions as opposed to three composite actions like I use in the example above. It would work just as well, it just isn't as "fancy" as the example above in setting an output parameter and ensuring proper handling of the Docker credentials. Feel free to borrow upon this idea as well! +{: .prompt-tip } + +## Summary + +We sometimes forget that GitHub Actions serves both the opensource and enterprise community. Some consider it a missing feature or limitation that you can't authenticate to a private image registry for Docker container actions, but I can understand how the experience using marketplace actions would be hindered if some actions had a requirement to authenticate to a private image registry before running. However, with a little creativity, we can still use Docker container actions with a private image registry without compromising too much on the user experience and security of your workflows. + +If you were only using self-hosted runners with no possibility of ever using GitHub-hosted runners, customizing the container commands is probably the best way to go. But if you want to use GitHub-hosted runners and be able to take advantage of the benefits of not having to maintain your own runner infrastructure, the composite action method is your best bet. + +And remember, sometimes Docker container actions are the exact right tool for the job, but sometimes they introduce unnecessary complexity and overhead using containers for container's sake to run simple commands. + +Let me know if you have any feedback or suggestions in the comments ⬇️. Happy Actioning!! 🚀 🚢 + +## Footnotes + +[^footnote-1]: Docker container Actions only work on Linux runners, not Windows or macOS runners. Docker container Actions also don't work on Actions-Runner-Controller using Kubernetes mode (only Docker-in-Docker mode). diff --git a/_posts/2024-05-24-vscode-yaml-indenting.md b/_posts/2024-05-24-vscode-yaml-indenting.md new file mode 100644 index 0000000..8c64f81 --- /dev/null +++ b/_posts/2024-05-24-vscode-yaml-indenting.md @@ -0,0 +1,72 @@ +--- +title: 'Preventing Auto-Indentation on Paste in VS Code for YAML Files' +author: Josh Johanning +date: 2024-05-24 16:00:00 -0500 +description: Stop VS Code from messing up your YAML indentation on paste +categories: [macOS, Development Environment] +tags: [VS Code, Development Environment] +media_subpath: /assets/screenshots/2024-05-24-vscode-yaml-indenting +image: + path: yaml-pasting-indenting-broken.gif + width: 100% + height: 100% + alt: VS Code erroneously auto-indenting YAML on paste +--- + +## Overview + +This is a quick post on making your VS Code better and to stop it from auto-indenting your YAML indentation when pasting. Without this, every time we paste in YAML content into a YAML file in VS Code, it tries to auto-indent which usually ends up messing up the indentation. Unless you like using `Shift` + `Tab` every time you paste into a GitHub Actions workflow file, this is a must-have setting. + +If you're unsure of what I'm talking about, look at the GIF above and see a simply copy/paste in the same file adds an additional level of indenting that I do not want in my YAML file. + +## The Fix + +In VS Code, open the command palette (`CMD`/`CTRL` + `Shift` + `P`) and type `> Preferences: Open User Settings (JSON)`. + +Add the following setting to the end of your `settings.json`{: .filepath} file: + +```json + { + "[yaml]": { + "editor.autoIndent": "keep", + "editor.tabSize": 2, + }, + "[github-actions-workflow]": { + "editor.autoIndent": "keep", + "editor.tabSize": 2, + } + } +``` +{: file='settings.json'} + +This setting will prevent auto-indentation on paste for YAML files. You'll notice that there's 2 blocks: the `[yaml]` block and the `[github-actions-workflow]` block. If you have the VS Code extension for GitHub Actions, the `[github-actions-workflow]` is required. With this, you can set different settings GitHub Action YAML files and other YAML files. + +I'm also setting the tab size to 2 spaces for YAML files, but you can adjust this to your preference. + +Now, when we paste, we see that the indentation is preserved: + +![With editor.autoIndent enabled, paste works again!](yaml-pasting-indenting-fixed.gif){: .shadow } +_With editor.autoIndent enabled, paste works again!_ + +Alternatively, you can prevent the auto-indentation on paste for all file types by adding the following setting: + +```json + "editor.autoIndent": "keep" +``` +{: file='settings.json'} + +Here's more background on the `editor.autoIndent` setting: + +```text + // Controls whether the editor should automatically adjust the indentation when users type, paste, move or indent lines. + // - none: The editor will not insert indentation automatically. + // - keep: The editor will keep the current line's indentation. + // - brackets: The editor will keep the current line's indentation and honor language defined brackets. + // - advanced: The editor will keep the current line's indentation, honor language defined brackets and invoke special onEnterRules defined by languages. + // - full: The editor will keep the current line's indentation, honor language defined brackets, invoke special onEnterRules defined by languages, and honor indentationRules defined by languages. +``` +{: .nolineno} + +## Summary + +When copying and pasting YAML code, such as GitHub Actions workflows or steps, into VS Code, this is a must have setting. I hope this helps! 🚀 diff --git a/_posts/2024-06-14-github-context.md b/_posts/2024-06-14-github-context.md new file mode 100644 index 0000000..7a5837b --- /dev/null +++ b/_posts/2024-06-14-github-context.md @@ -0,0 +1,136 @@ +--- +title: 'GitHub Actions: Working with the GitHub Context' +author: Josh Johanning +date: 2024-06-14 10:30:00 -0500 +description: Take your Actions knowledge to the next level by mastering the GitHub context +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, IssueOps] +media_subpath: /assets/screenshots/2024-06-14-github-context +image: + path: github-context-dark.png + width: 100% + height: 100% + alt: Printing the GitHub context +--- + +## Overview + +Understanding the GitHub context, and how to print out the entire context, can be super useful when working with GitHub Actions. It provides information about the workflow run, the repository, and the event that triggered the workflow. This context is available to every step in a workflow run and can be used in expressions, conditions, and even as out of the box variables. + +## Contexts + +{% raw %} + +There are actually several [contexts](https://docs.github.com/en/actions/learn-github-actions/contexts) in addition to the GitHub context, such as the `env`, `job`, `jobs`, `steps`, `runner`, `secrets`, `strategy`, `matrix`, `needs`, and `inputs` contexts. You probably reference some of these without knowing, such as when you're referencing a secret you would use `${{ secrets.MY_SECRET }}`, or when you are using an input from the workflow with `${{ inputs.my_input }}`. + +The GitHub context is the most probably the most commonly used and most helpful context that you don't already know about that provides information about the workflow run, the repository, and the event that triggered the workflow. You might be already using the `github` context without knowing it, such as when you're referencing the repository name with `${{ github.repository }}` or the branch/tag name with `${{ github.ref_name }}`. However, there are so many more additional properties that you can use! For example, in a workflow trigger via a pull request, you can access the pull request number with `${{ github.event.pull_request.number }}`. + +## Working with the GitHub Context + +Whenever I'm working on a complex workflow, I always start by printing out the entire GitHub context to see what's available. This is super easy to do with a simple step like this: + +```yaml + - name: Write GitHub context to log + env: + GITHUB_CONTEXT: ${{ toJSON(github) }} + run: echo "$GITHUB_CONTEXT" +``` + +You want to do it this way (setting the `GITHUB_CONTEXT` environment variable) for string escaping purposes (if you try to `echo '${{ toJSON(github) }}'` directly, this will sometimes error out). Also, this is a good practice to [mitigate against script injection attacks](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#good-practices-for-mitigating-script-injection-attacks) since we're directly printing user input! + +This will print out the entire GitHub context to the log, which you can then use to reference the properties you need. Traverse the JSON to see what's available and what you can use in your workflow. In this example, I can use `${{ github.event.enterprise.name }}` to get the enterprise name of the repository running the workflow. + +![Printing the GitHub context](github-context-dark.png){: .shadow }{: .dark } +![Printing the GitHub context](github-context-light.png){: .shadow }{: .light } +_Printing the GitHub context_ + +You can even use the contexts in expressions. For example, we can conditionally run a job based on the labels of an issue that triggered the workflow: + +```yml +on: + issues: + types: [opened] + +jobs: + new-repo-create: + runs-on: ubuntu-latest + if: contains(github.event.issue.labels.*.name, 'new-repo') + steps: + - name: print issue title + env: + ISSUE_TITLE: ${{ github.event.issue.title }} + run: echo "Issue title $ISSUE_TITLE" + - name: print issue body + env: + ISSUE_BODY: ${{ github.event.issue.body }} + run: echo "Issue body $ISSUE_BODY" + - name: print issue author + env: + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + run: echo "Issue author $ISSUE_AUTHOR" + - name: print issue number + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: echo "Issue number $ISSUE_NUMBER" +``` +{: file='.github/workflows/new-repo-create.yml'} + +This can be super helpful for IssueOps and LabelOps scenarios! + +> Follow this pattern of setting any user-provided input to an environment variable before using it in a script to [mitigate against script injection attacks](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#good-practices-for-mitigating-script-injection-attacks). +{: .prompt-tip } + +## Exporting all Contexts + +You can do the same thing to print out the other contexts as well. Here's a full workflow example: + +```yml +jobs: + write_contexts_to_log: + runs-on: ubuntu-latest + steps: + - name: Write GitHub context to log + env: + GITHUB_CONTEXT: ${{ toJSON(github) }} + run: echo "$GITHUB_CONTEXT" + - name: Write job context to log + env: + JOB_CONTEXT: ${{ toJSON(job) }} + run: echo "$JOB_CONTEXT" + # this errors out if you try to access it w/o using a reusable workflow + # - name: Write jobs context to log (reusable workflows) + # env: + # JOB_CONTEXT: ${{ toJSON(jobs) }} + # run: echo "$JOBS_CONTEXT" + - name: Write steps context to log + env: + STEPS_CONTEXT: ${{ toJSON(steps) }} + run: echo "$STEPS_CONTEXT" + - name: Write runner context to log + env: + RUNNER_CONTEXT: ${{ toJSON(runner) }} + run: echo "$RUNNER_CONTEXT" + - name: Write strategy context to log + env: + STRATEGY_CONTEXT: ${{ toJSON(strategy) }} + run: echo "$STRATEGY_CONTEXT" + - name: Write matrix context to log + env: + MATRIX_CONTEXT: ${{ toJSON(matrix) }} + run: echo "$MATRIX_CONTEXT" + - name: Write env context to log + env: + ENV_CONTEXT: ${{ toJSON(env) }} + run: echo "$ENV_CONTEXT" + - name: Write secrets context to log + env: + SECRETS_CONTEXT: ${{ toJSON(secrets) }} + run: echo "$SECRETS_CONTEXT" +``` +{: file='.github/workflows/write-contexts-to-log.yml'} + +{% endraw %} + +## Summary + +Once you understand the GitHub context, so many more variable combinations and expressions become available to you. This can help you write more dynamic and flexible workflows that can adapt to different scenarios. I hope this helps you take your GitHub Actions knowledge to the next level! 🚀 diff --git a/_posts/2024-06-18-github-composite-action-python.md b/_posts/2024-06-18-github-composite-action-python.md new file mode 100644 index 0000000..936ea8c --- /dev/null +++ b/_posts/2024-06-18-github-composite-action-python.md @@ -0,0 +1,165 @@ +--- +title: 'GitHub Actions: Create a Composite Action in Python' +author: Josh Johanning +date: 2024-06-18 19:30:00 -0500 +description: Create a composite action in Python 🐍 to reduce code duplication and improve maintainability +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions, Python, Composite Actions] +media_subpath: /assets/screenshots/2024-06-18-github-composite-action-python +image: + path: composite-action-light.png + width: 100% + height: 100% + alt: A composite action in GitHub Actions written in Python +--- + +## Overview + +In GitHub Actions, a [composite action is a type of action](https://docs.github.com/en/actions/creating-actions/about-custom-actions#types-of-actions) that allows you to combine multiple steps into a single action. This can help reduce code duplication and improve maintainability of your workflow files. In a composite action, you can combine multiple run steps, multiple marketplace actions, or a combination of both! Composite actions are my favorite type of action because of their flexibility to run *anything* in any language/framework/etc. on any host. If it can run programmatically, you can build it as a composite action. In this post, we'll create a composite action in Python in a way that can be used in Actions as well as preserving the ability to test/run the script locally. + +## Composite Action in Python + +In a composite action, we have to specify the [shell](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defaultsrunshell) for each run step. Most commonly, I use `shell: bash`, but there is an option for `shell: python` directly. If it was a 2 line script, sure, use `shell: python`, but for anything more complex, I prefer to use `shell: bash` and call the Python script from shell. This allows me to use the same Python script in the composite action as well as run it locally for testing. + +Let's show some examples. + +### Preferred Python Composite Action + +{% raw %} + +This is the preferred way to create a Python composite action. Note how we are storing the Python script in a separate file and not directly inline. This allows you to run the Python script locally for testing as well as using in GitHub Actions as a composite action. And if you ever switch CI systems, it would be easy to port since the only "Actions" specific code is small bit in the `action.yml`{: .filepath} file. + +Here's the example: + +```yml +name: 'Python composite action' +description: 'call a python script from a composite action' +inputs: + directory: + description: 'directory path as an example input' + required: true + default: '${{ github.workspace }}' + token: + description: 'github auth token (PAT, github token, or GitHub app token)' + required: true + default: '${{ github.token }}' +runs: + using: "composite" + steps: + - name: run python + shell: bash + run: | + python3 ${{ github.action_path }}/main.py ${{ inputs.directory }} ${{ inputs.token }} +``` +{: file='action.yml'} + +The magic 🪄 is that we are calling the Python script from the shell using the `${{ github.action_path }}` [environment variable](https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables). This variable maps to the local directory the action is cloned to so that we can reference files from the repo. + +You can then run/test/debug/develop the Python script locally as you would any other Python script: + +```bash +python3 main.py /path/to/directory ghp_abcdefg1234567890 +``` +{: .nolineno} + +Store the Python script in the composite action repository. If this was the only action in the repository, it probably makes the most sense to put in the script in the root along with the `action.yml`{: .filepath} file (this is how I'm doing it in the example above): + +```text +. +├── README.md +├── action.yml +└── main.py +``` +{: .nolineno} + +If you had multiple composite actions in the same repository, you could structure it like so: + +```text +. +├── README.md +├── python-action-1/ +│ ├── README.md +│ ├── action.yml +│ └── main.py +├── python-action-2/ +│ ├── README.md +│ ├── action.yml +│ └── main.py +└── python-other-action/ + ├── README.md + ├── action.yml + └── main.py +``` +{: .nolineno} + +> Note that the entire repository will be versioned together when creating/referencing tags, so you may only want to do this if the actions are closely related. +{: .prompt-tip } + +You could also do something like this, creating separate folders for the actions (since only one `action.yml`{: .filepath} can exist in a single directory) and then use a combined `./src`{: .filepath} folder for the Python scripts: + +```text +. +├── README.md +├── python-action-1/ +│ ├── README.md +│ └── action.yml +├── python-action-2/ +│ ├── README.md +│ └── action.yml +├── python-other-action/ +│ ├── README.md +│ └── action.yml +└── src/ + ├── action-1.py + ├── action-2.py + └── other-action.py +``` +{: .nolineno} + +Or, of course, a combination of whatever makes the most sense for your use case. 😎 + +### Non-optimal Python Composite Action + +Ideally, you wouldn't do this. We cannot run or test this locally, and especially for a longer script, it makes the `action.yml`{: .filepath} file harder to read and maintain. + +```yml +name: 'Python composite action' +description: 'call a python script from a composite action' +inputs: + directory: + description: 'directory path as an example input' + required: true + default: '${{ github.workspace }}' + token: + description: 'github auth token (PAT, github token, or GitHub app token)' + required: true + default: '${{ github.token }}' +runs: + using: "composite" + steps: + - name: run python + shell: bash + run: | + import sys + + def main(filePath, creds): + print("Hello World") + print(f"File Path: {filePath}") + + if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python3 myfile.py ") + sys.exit(1) + filePath = sys.argv[1] + creds = sys.argv[2] + main(${{ inputs.directory }}, ${{ inputs.token }}) +``` +{: file='action.yml'} + +This certainly works, but you can see that it's not as flexible/portable as the [preferred method above](#preferred-python-composite-action). For one, if you wanted to run this locally, you would have to copy/paste and then swap the hardcoded GitHub Actions-isms, like in this example: `${{ inputs.directory }}` and `${{ inputs.token }}`. It's also harder to read and maintain. 😬 + +{% endraw %} + +## Summary + +Composite Actions are great! The barrier to entry for creating custom actions are much less than that of JavaScript actions, and in general, I [don't typically recommend Docker-based actions](/posts/github-actions-docker-actions-private-registry/#overview). This post shows a great, real-world example of creating a composite action in our preferred language. I would even use this method to create composite actions written in Bash. Instead of running `python3 main.py` you would just call the Bash script via `./main.sh`. 🐍 🚀 diff --git a/_posts/2024-08-14-github-organization-readme-badge-generator.md b/_posts/2024-08-14-github-organization-readme-badge-generator.md new file mode 100644 index 0000000..398d087 --- /dev/null +++ b/_posts/2024-08-14-github-organization-readme-badge-generator.md @@ -0,0 +1,135 @@ +--- +title: 'GitHub Organization Readme Badge Generator' +author: Josh Johanning +date: 2024-08-14 3:30:00 -0500 +description: A GitHub action to create markdown badges for your GitHub organization's README.md file +categories: [GitHub, Actions] +tags: [GitHub, GitHub Actions] +media_subpath: /assets/screenshots/2024-08-14-github-organization-readme-badge-generator +image: + path: markdown-badges-light-header.png + width: 100% + height: 100% + alt: Markdown badges in a GitHub organization's README +--- + +## Overview + +In this post, I will show you how to add an Actions workflow to generate markdown badges to spruce up your organization READMEs. Badges are a great way to provide a quick visual representation of data; you might see them often on your favorite open source repository showing the code quality or number of stars. My action will generate badges for the following (right now): + +- Number of repositories +- Number of pull requests open in the last 30 days +- Number of pull requests merged in the last 30 days + +Here's an [example](https://github.com/joshjohanning-org#joshjohanning-org) of this in action: +![Markdown badges in a GitHub organization's README](markdown-badges-light.png){: .shadow }{: .light } +![Markdown badges in a GitHub organization's README](markdown-badges-dark.png){: .shadow }{: .dark } +_Markdown badges in a GitHub organization's README_ + +## What is an organization README? + +[Organization READMEs](https://github.blog/changelog/2021-09-14-readmes-for-organization-profiles/) are a great way to showcase your organization to the world. Take [GitHub's organization README](https://github.com/github) as an example. There's a fun picture, a description of what's in the organization, how to contribute, and much more. [Member-only READMEs](https://github.blog/changelog/2022-04-20-organization-profile-updates-member-only-readmes-and-pinned-private-repositories/) are also a great extension of this functionality. Instead of providing information to the general public, you can provide information to your organization's members. For example, when I navigate to GitHub's organization page, I see a different README by default with internal information. + +It's really easy to create a public and/or member-only organization README. For a public organization README, you just need to create a `.github` repository and add a `profile/README.md`{: .filepath} file. For an organization member-only README, create a `.github-private` repository and add a `profile/README.md`{: .filepath} file. + +## The Workflow + +My [action](https://github.com/joshjohanning/organization-readme-badge-generator) is fairly generic in the sense that all it does is generate the markdown badges. It's up to you to decide where to put them in your README. Here's an example of me overwriting some placeholder text by using my action and a bash script: + +{% raw %} + +```yml +name: update-organization-readme-badges + +on: + schedule: + - cron: '0 7 * * *' # runs daily at 07:00 + workflow_dispatch: + +permissions: + contents: write + +jobs: + generate-badges: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: organization-readme-badge-generator + id: organization-readme-badge-generator + uses: joshjohanning/organization-readme-badge-generator@v1 + with: + organization: ${{ github.repository_owner }} + token: ${{ steps.app-token.outputs.token }} # recommend to use a GitHub App and not a PAT + + - name: write to job summary + run: | + echo "${{ steps.organization-readme-badge-generator.outputs.badges }}" >> $GITHUB_STEP_SUMMARY + + - name: add to readme + run: | + readme=profile/README.md + + # get SHA256 before + beforeHash=$(sha256sum $readme | awk '{ print $1 }') + + # Define start and end markers + startMarker="" + endMarker="" + + replacement="${{ steps.organization-readme-badge-generator.outputs.badges }}" + + # Escape special characters in the replacement text + replacementEscaped=$(printf '%s\n' "$replacement" | perl -pe 's/([\\\/\$\(\)@])/\\$1/g') + + # Use perl to replace the text between the markers + perl -i -pe "BEGIN{undef $/;} s/\Q$startMarker\E.*?\Q$endMarker\E/$startMarker\n$replacementEscaped\n$endMarker/smg" $readme + # get SHA256 after + afterHash=$(sha256sum $readme | awk '{ print $1 }') + # Compare the hashes and commit if required + if [ "$afterHash" = "$beforeHash" ]; then + echo "The hashes are equal - exiting script" + exit 0 + else + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add $readme + git commit -m "docs: update organization readme badges" + git push + fi +``` +{: file='.github/workflows/update-organization-readme-badges.yml'} + +{% endraw %} + +> I have this [workflow](https://github.com/joshjohanning-org/.github/actions/workflows/update-organization-readme-badges.yml) running in my public `joshjohanning-org/.github` repository for reference. +{: .prompt-tip } + +This workflow runs and generates the badges with my action. Afterwards, it finds placeholder text in the `profile/README.md`{: .filepath} file and updates the markdown badges. The workflow then commits the change and pushes it back to the repository. + +Here's an example of the placeholder text in the `profile/README.md`{: .filepath} file that it's expecting (your badges would be dynamically inserted between the tags): + +```md +# my-org-name + + + + +``` +{: file='profile/README.md'} + +## Summary + +I like the ability to add quick little badges to my organization README to give the public / my members an idea of the status and activity stats for the organization. This action is a great way to do that. + +I have this running as a scheduled workflow in both my `.github` and `.github-private` repositories. + +I hope you find this useful! Let me know if there are other features or badges that I should add! For example, one of the things I was [envisioning](https://github.com/joshjohanning/organization-readme-badge-generator/issues/4) was number of GitHub Actions workflows ran / successful.. 🚀 diff --git a/_posts/2024-09-18-github-migration-tools.md b/_posts/2024-09-18-github-migration-tools.md new file mode 100644 index 0000000..101e023 --- /dev/null +++ b/_posts/2024-09-18-github-migration-tools.md @@ -0,0 +1,102 @@ +--- +title: 'Enhancing GitHub Migrations with Additional Tooling' +author: Josh Johanning +date: 2024-09-18 10:30:00 -0500 +description: A collection of additional tools to assist with your GitHub migration experience +categories: [GitHub, Migrations] +tags: [GitHub, Migrations, Azure DevOps, Git, gh cli, Scripts, Bitbucket] +--- + +## Overview + +When migrating to GitHub, there are a couple of options. You could do a [`git clone --mirror / git push`](/posts/migrating-repos-to-github/#option-2-git-clone-mirror), but if you're migrating from another GitHub source, Azure DevOps, or Bitbucket Server, you should be using the official GitHub Enterprise Importer (GEI) tooling. This not only migrates the code with the history, but also additional metadata such as pull requests, issues (if GitHub), existing branch protection settings, and more. + +GEI is great, but [not everything is migrated](https://docs.github.com/en/migrations/using-github-enterprise-importer/migrating-between-github-products/about-migrations-between-github-products#data-that-is-not-migrated-1). Don't worry; there are some additional tools that can help enhance the migration experience and fill in these gaps. This post contains a collection of open-source tools that I've found useful when migrating to GitHub. + +## Supplementary Migration Tooling Collection + +Based on my migration experience, here are additional tools I've found useful, organized by items not migrated by GEI and the corresponding supplementary tooling: + +| Item | Tooling | Notes | +|------|---------|-------| +| **Organization** | | | +| - Metadata | N/a | Name of org, description, settings, OAuth app policy, scheduled reminders, org owners, etc. | +| - Custom repo roles | [Analysis script](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organizations-custom-repository-roles-count.sh) | Any custom org roles will need to be migrated as well | +| - Org level webhooks | [Analysis script (count)](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/gh-cli/get-organizations-webhooks-count.sh),
[Analaysis script (detailed)](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organizations-webhooks.sh),
[gh-organization-webhooks](https://github.com/katiem0/gh-organization-webhooks) | Need to know what webhook secrets are, can't retrieve in UI/API | +| - IP allow list | [Get org IP allow list](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organization-ip-allow-list.sh),
[Get enterprise IP allow list](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-enterprise-ip-allow-list.sh),
[Set IP allow list rules for](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/set-ip-allow-list-rules.sh) | The get scripts save rules to CSV and the set script sets them in target | +| **Discussions** | [Analysis script (count) for each org](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organizations-discussions-count.sh),
[Analysis script (count) for each repo in an org](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organizations-repositories-discussions-count.sh) | Discussions exist in repos, but may have to configure which repo will be used for org discussions | +| **Projects** | | | +| - Projects v2 | [Analysis script](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organizations-projects-count.sh),
[gh-migrate-project](https://github.com/timrogers/gh-migrate-project?tab=readme-ov-file) | CLI utility can help migrate org-level projects | +| - Org Projects (classic) | [Analysis script](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organizations-projects-count-classic.sh) | Deprecated | +| - Repo Projects (classic) | N/a | Deprecated | +| **GitHub apps** | [Analysis script for org apps](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organizations-apps.sh),
[Analysis script by org app count](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organizations-apps-count.sh) | The [manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) help when recreating apps manually | +| **Teams / membership** | [gh-migrate-teams](https://github.com/mona-actions/gh-migrate-teams),
[gh-migrate-team-permission](https://github.com/mona-actions/gh-migrate-team-permission),
[Recreate security in repos & teams](https://github.com/joshjohanning/github-misc-scripts/tree/main/scripts/recreate-security-in-repositories-and-teams),
[Create teams from list](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/create-teams-from-list.sh),
[gh-collaborators](https://github.com/katiem0/gh-collaborators) | Use the [recreation script](https://github.com/joshjohanning/github-misc-scripts/tree/main/scripts/recreate-security-in-repositories-and-teams) if wanting to mirror teams/membership | +| **User settings** | N/a | PATs, SSH keys, notification settings | +| **Webhook secrets** | [Script to analyze webhooks](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-repositories-webhooks-csv.sh),
[gh-migrate-webhook-secrets CLI](https://github.com/mona-actions/gh-migrate-webhook-secrets) | Repo-level webhooks migrate, but webhook secrets need to be recreated | +| **Actions** | | Action runs don't migrate, workflows will migrate with code, and everything below will need to be recreated | +| - Repo/org secrets | [gh-secrets-migrator](https://github.com/dylan-smith/gh-secrets-migrator),
[gh-seva](https://github.com/katiem0/gh-seva?tab=readme-ov-file) | Actions secrets values can only be retrieved during Actions runtime | +| - Environments | [gh-environments](https://github.com/katiem0/gh-environments) | Environments need to be recreated | +| - Variables | [gh-seva](https://github.com/katiem0/gh-seva?tab=readme-ov-file) | Variables need to be recreated | +| - Self-hosted runners | [Analysis script for all org runners](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organization-self-hosted-runners-organization-runners.sh),
[Analysis script for all repo runners in org](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organization-self-hosted-runners-repository-runners.sh),
[Analysis script for repo+org runners in org](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organization-self-hosted-runners-all-runners.sh),
[Analysis script for enterprise runners](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-enterprise-self-hosted-runners.sh) | Runners need to be re-created | +| - Larger runners | N/a | Larger GitHub-hosted runners need to be re-created; no API for large runners | +| **Rulesets** | [gh-migrate-rulesets](https://github.com/katiem0/gh-migrate-rulesets) | Rulesets are not migrated | +| **Packages** | [See package migration posts](/categories/packages/) | Packages are not migrated | +| **Code owners** | [Analysis script](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-organizations-repositories-codeowner-usage.sh),
[Code owners mapping helper](https://github.com/joshjohanning/github-misc-scripts/blob/main/scripts/update-codeowners-mappings.js) | Updating org/team names | +| **LFS** | [Migrate LFS artifacts](/posts/migrate-git-lfs-artifacts/) | LFS is not migrated | +| **Username mapping** | [Getting SAML entities at enterprise](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-saml-identities-in-enterprise.sh),
[Getting SAML entities at org](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-saml-identities-in-organization.sh) | Getting SAML identities can help map personal github.com accounts by tying their email to their identity provider credential | +| **Repository visibility** | [gh-repo-visibility](https://github.com/mona-actions/gh-repo-visibility) | Repos migrate as private by [default](https://docs.github.com/en/migrations/using-github-enterprise-importer/migrating-between-github-products/overview-of-a-migration-between-github-products#setting-repository-visibility) | +| **Deploy keys** | [gh-migrate-deploy-keys](https://github.com/mona-actions/gh-migrate-deploy-keys) | Deploy keys are not migrated | + +## Migration Planning Tooling + +These tools are more for helping you plan / track a migration. For example, you can use some of these tools to potentially identify problem repositories before you start the migration (e.g. with `git-sizer`, repositories that contain large files committed), or otherwise repositories with a lot of pull requests that will take longer to migrate. + +| Tool | Description | +| --- | --- | +| **[gh-repo-stats](https://github.com/mona-actions/gh-repo-stats)** | GitHub CLI extension to pull statistics on repository metadata used in GitHub migrations | +| **[gh-migration-audit](https://github.com/timrogers/gh-migration-audit)** | Audits GitHub repositories to highlight data that cannot be automatically migrated using GitHub's migration tools | +| **[gh ado2gh inventory-report](https://docs.github.com/en/enterprise-cloud@latest/migrations/overview/planning-your-migration-to-github#building-a-basic-inventory-of-the-repositories-you-want-to-migrate)** | Azure DevOps to GitHub inventory report using the GEI commands | +| **[git-sizer](https://github.com/github/git-sizer)** & **[gh-sizer](https://github.com/timrogers/gh-sizer)** | Compute various size metrics for a Git repository, flagging those that might cause problems | +| **[gh-bbs-analyzer](https://github.com/mona-actions/gh-bbs-analyzer)** | GitHub CLI extension for analyzing BitBucket Server to get migration statistics | +| **[gh-gitlab-stats](https://github.com/mona-actions/gh-gitlab-stats)** | GitHub CLI extension to pull statistics on GitLab repository and server metadata | +| **[gh-pma](https://github.com/mona-actions/gh-pma)** | Post-Migration Audit (PMA) Extension For GitHub CLI | +| **[github-migration-monitor](https://github.com/timrogers/github-migration-monitor)** | Monitors GitHub Enterprise Importer (GEI) migrations for an organization through a simple command line tool | + +## Non-technical Migration Planning Tips + +Here are list of miscellaneous non-technical considerations to keep in mind when planning a migration: + +- You should have training material in a wiki or similar for developers to reference. This should include a list of "what's changing" and what things developers need to do in the new environment. Things to consider: + - Either adding a new remote to a local repository or cloning a new repository from the EMU + - Recreating PATs and SSH keys (note that SSH keys have to be globally unique in github.com; if they want to re-use an existing key, they will have to remove it from the old account first) + - For PATs and SSH keys, note that users may have to [authorize tokens for SSO](https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-saml-single-sign-on/authorizing-a-personal-access-token-for-use-with-saml-single-sign-on) after creating them, which may be a new step for them +- Creating a migration plan, documenting what repositories are moving and when, as well as who is responsible for each pre and post-migration step +- Running through a dry run first, which helps you identify if there will be any issues ahead of time, as well as getting a sense of the timing + - Dry run means running a migration, but don't cut over or lock the source (you should be able to run the migration again and again with the same repositories and have the same results) +- If you are migrating from github.com to another enterprise in github.com, the new organization name must be globally unique - however, you can rename the original organization and then right after, rename the new organization to the original name + - Make sure you don't delete any organization where you want to keep the name, as the name will be locked for 90 days after the deletion + - It is better to rename and then delete in this case +- Links + - If org name or repo name changes, existing links will be broken + - Private/non-public GitHub Pages will have a new URL, so any links to the old pages will be broken + +## Special Thanks + +Special thanks to everyone who has contributed to these OSS tools! + +- [katiem0](https://github.com/katiem0) +- [tspascoal](https://github.com/tspascoal) +- [timrogers](https://github.com/timrogers) +- [mickeygousset](https://github.com/mickeygousset) +- [dylan-smith](https://github.com/dylan-smith) +- [amenocal](https://github.com/amenocal) +- [andyfeller](https://github.com/andyfeller) +- [antgrutta](https://github.com/antgrutta) +- [bryantson](https://github.com/bryantson) +- [pmartindev](https://github.com/pmartindev) +- [samueljmello](https://github.com/samueljmello) +- And many more I am SURE I am missing + +## Summary + +Hopefully, this collection of migration tooling and tips helps you out! I'll use this post as a living document and update it as I find new tools or tips. If you have any suggestions or additions, please add a comment! 🙌 ✨ diff --git a/_posts/2024-11-15-github-sub-issues-and-issue-types.md b/_posts/2024-11-15-github-sub-issues-and-issue-types.md new file mode 100644 index 0000000..4045d1a --- /dev/null +++ b/_posts/2024-11-15-github-sub-issues-and-issue-types.md @@ -0,0 +1,266 @@ +--- +title: 'GitHub Issues: Scripts for working with Sub-Issues and Issue Types' +author: Josh Johanning +date: 2024-11-15 1:00:00 -0600 +description: A collection of scripts for working with sub-issues and issue types in GitHub Issues +categories: [GitHub, Scripts] +tags: [GitHub, GitHub Issues, GitHub Projects] +media_subpath: /assets/screenshots/2024-11-15-github-sub-issues-and-issue-types +image: + path: sub-issues-issue-types-light.png + width: 100% + height: 100% + alt: Sub-Issues and Issue Types in GitHub Issues +--- + +## Overview + +Public previews for [Sub-Issues](https://github.com/orgs/community/discussions/139932) and [Issue Types](https://github.com/orgs/community/discussions/139933) have recently shipped, and they are *awesome*! 🎉 I encourage you to sign your org up for the opt-in public preview [here](https://github.com/features/issues/signup). + +I was looking to do some automation with sub-issues and issue types, and noticed that right now we have to use the GraphQL API to work with them. To run certain queries and mutations, we need the GraphQL IDs of the fields, which if you don't know GraphQL, can be a bit of a challenge. I created these helper scripts to abstract this process and make automation much easier. 🚀 + +> Check out [@mickeygousset](https://github.com/mickeygousset)'s videos for working with [sub-issues](https://www.youtube.com/watch?v=F42FN6cZmA4) and [issue types](https://www.youtube.com/watch?v=2wVmcuCC1is)! ✨ +{: .prompt-info } + +## The Scripts + +### Sub-Issue Scripts + +- [`get-parent-issue-of-issue.sh`](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-parent-issue-of-issue.sh) +- [`get-sub-issues-of-issue.sh`](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-sub-issues-of-issue.sh) +- [`get-sub-issues-summary-of-issue.sh`](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-sub-issues-summary-of-issue.sh) +- [`add-sub-issue-to-issue.sh`](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/add-sub-issue-to-issue.sh) +- [`remove-sub-issue-from-issue.sh`](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/remove-sub-issue-from-issue.sh) + +### Issue Type Scripts + +- [`get-issue-type-of-issue.sh`](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/get-issue-type-of-issue.sh) +- [`update-issue-issue-type.sh`](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/update-issue-issue-type.sh) +- [`remove-issue-issue-type.sh`](https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/remove-issue-issue-type.sh) + +## Usage + +### Sub-Issue Scripts Usage + +#### get-parent-issue-of-issue.sh + +Gets the parent issue of the specified issue. + +Query: + +```sh +./get-parent-issue-of-issue.sh joshjohanning-org migrating-ado-to-gh-issues-v2 7 +``` +{: .nolineno} + +Response: + +```json +{ + "title": "Website enhancements", + "number": 5, + "url": "https://github.com/joshjohanning-org/migrating-ado-to-gh-issues-v2/issues/5", + "id": "I_kwDONO_ztc6eXvAG", + "issueType": "Feature" +} +``` +{: .nolineno} + +#### get-sub-issues-of-issue.sh + +Gets a list of sub-issues for the specified issue. + +Query: + +```sh +./get-sub-issues-of-issue.sh joshjohanning-org migrating-ado-to-gh-issues-v2 5 +``` +{: .nolineno} + +Response: + +```json +{ + "totalCount": 3, + "issues": [ + { + "title": "Fix login page", + "number": 6, + "url": "https://github.com/joshjohanning-org/migrating-ado-to-gh-issues-v2/issues/6", + "id": "I_kwDONO_ztc6eXvDa", + "issueType": "User Story" + }, + { + "title": "Increase contrast of members page", + "number": 7, + "url": "https://github.com/joshjohanning-org/migrating-ado-to-gh-issues-v2/issues/7", + "id": "I_kwDONO_ztc6eXvGo", + "issueType": null + }, + { + "title": "Add logout button", + "number": 8, + "url": "https://github.com/joshjohanning-org/migrating-ado-to-gh-issues-v2/issues/8", + "id": "I_kwDONO_ztc6eXvKm", + "issueType": null + } + ] +} +``` +{: .nolineno} + +#### get-sub-issues-summary-of-issue.sh + +Gets the sub-issues summary for the specified issue. + +Query: + +```sh +./get-sub-issues-summary-of-issue.sh joshjohanning-org migrating-ado-to-gh-issues-v2 5 +``` +{: .nolineno} + +Response: + +```json +{ + "total": 3, + "completed": 1, + "percentCompleted": 33 +} +``` +{: .nolineno} + +#### remove-sub-issue-from-issue.sh + +Removes the specified sub-issue from the specified parent issue. + +Query: + +```sh +./remove-sub-issue-from-issue.sh joshjohanning-org migrating-ado-to-gh-issues-v2 5 9 +``` +{: .nolineno} + +Response: + +```text +Child issue #9 is a sub-issue of parent issue #5. +{ + "data": { + "removeSubIssue": { + "issue": { + "title": "Website enhancements", + "number": 5, + "url": "https://github.com/joshjohanning-org/migrating-ado-to-gh-issues-v2/issues/5", + "id": "I_kwDONO_ztc6eXvAG", + "issueType": { + "name": "Feature" + } + }, + "subIssue": { + "title": "task 1", + "number": 9, + "url": "https://github.com/joshjohanning-org/migrating-ado-to-gh-issues-v2/issues/9", + "id": "I_kwDONO_ztc6eXvN3", + "issueType": null + } + } + } +} +Successfully removed issue joshjohanning-org/migrating-ado-to-gh-issues-v2#9 as a sub-issue to joshjohanning-org/migrating-ado-to-gh-issues-v2#5. +``` +{: .nolineno} + + +### Issue Types Scripts Usage + +#### get-issue-type-of-issue.sh + +Gets the issue type of the specified issue. + +Query: + +```sh +./get-issue-type-of-issue.sh joshjohanning-org migrating-ado-to-gh-issues-v2 5 +``` +{: .nolineno} + +Response: + +```json +{ + "title": "Website enhancements", + "number": 5, + "url": "https://github.com/joshjohanning-org/migrating-ado-to-gh-issues-v2/issues/5", + "id": "I_kwDONO_ztc6eXvAG", + "issueType": "Feature" +} +``` +{: .nolineno} + +#### update-issue-issue-type.sh + +Updates/sets the issue type of the specified issue. + +Query: + +```sh +./update-issue-issue-type.sh joshjohanning-org migrating-ado-to-gh-issues-v2 6 "user story" +``` +{: .nolineno} + +Response: + +```json +{ + "data": { + "updateIssueIssueType": { + "issue": { + "title": "Fix login page", + "number": 6, + "url": "https://github.com/joshjohanning-org/migrating-ado-to-gh-issues-v2/issues/6", + "id": "I_kwDONO_ztc6eXvDa", + "issueType": { + "name": "User Story" + } + } + } + } +} +``` +{: .nolineno} + +#### remove-issue-issue-type.sh + +Removes the issue type from the specified issue. + +Query: + +```sh +./remove-issue-issue-type.sh joshjohanning-org migrating-ado-to-gh-issues-v2 6 +``` +{: .nolineno} + +Response: + +```json +{ + "data": { + "updateIssueIssueType": { + "issue": { + "title": "Fix login page", + "number": 6, + "url": "https://github.com/joshjohanning-org/migrating-ado-to-gh-issues-v2/issues/6", + "id": "I_kwDONO_ztc6eXvDa", + "issueType": null + } + } + } +} +``` +{: .nolineno} + +## Summary + +Working with GraphQL can sometimes be challenging, especially if you're more familiar with REST APIs. I hope you find these scripts helpful! Please let me know if you have any questions or feedback. 🚀 Keep an eye on [the changelog](https://github.blog/changelog/label/projects/) for new Issues/Projects features! diff --git a/_sass/addon/commons.scss b/_sass/addon/commons.scss new file mode 100644 index 0000000..47da2ab --- /dev/null +++ b/_sass/addon/commons.scss @@ -0,0 +1,1541 @@ +/* The common styles */ + +html { + font-size: 16px; + + @media (prefers-color-scheme: light) { + &:not([data-mode]), + &[data-mode='light'] { + @include light-scheme; + } + + &[data-mode='dark'] { + @include dark-scheme; + } + } + + @media (prefers-color-scheme: dark) { + &:not([data-mode]), + &[data-mode='dark'] { + @include dark-scheme; + } + + &[data-mode='light'] { + @include light-scheme; + } + } +} + +body { + background: var(--main-bg); + padding: env(safe-area-inset-top) env(safe-area-inset-right) + env(safe-area-inset-bottom) env(safe-area-inset-left); + color: var(--text-color); + -webkit-font-smoothing: antialiased; + font-family: $font-family-base; +} + +/* --- Typography --- */ + +@for $i from 1 through 5 { + h#{$i} { + @extend %heading; + + @if $i > 1 { + @extend %anchor; + } + + @if $i < 5 { + $size-factor: 0.25rem; + + @if $i > 1 { + $size-factor: 0.18rem; + + main & { + @if $i == 2 { + margin: 2.5rem 0 1.25rem; + } @else { + margin: 2rem 0 1rem; + } + } + } + + & { + font-size: 1rem + (5 - $i) * $size-factor; + } + } @else { + font-size: 1.05rem; + } + } +} + +a { + @extend %link-color; + + text-decoration: none; +} + +img { + max-width: 100%; + height: auto; + transition: all 0.35s ease-in-out; + + .blur & { + $blur: 20px; + + -webkit-filter: blur($blur); + filter: blur($blur); + } +} + +blockquote { + border-left: 0.125rem solid var(--blockquote-border-color); + padding-left: 1rem; + color: var(--blockquote-text-color); + margin-top: 0.5rem; + + > p:last-child { + margin-bottom: 0; + } + + &[class^='prompt-'] { + border-left: 0; + position: relative; + padding: 1rem 1rem 1rem 3rem; + color: var(--prompt-text-color); + + @extend %rounded; + + &::before { + text-align: center; + width: 3rem; + position: absolute; + left: 0.25rem; + margin-top: 0.4rem; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + } + } + + @include prompt('tip', '\f0eb', $fa-style: 'regular'); + @include prompt('info', '\f06a', $rotate: 180); + @include prompt('warning', '\f06a'); + @include prompt('danger', '\f071'); +} + +kbd { + font-family: Lato, sans-serif; + display: inline-block; + vertical-align: middle; + line-height: 1.3rem; + min-width: 1.75rem; + text-align: center; + margin: 0 0.3rem; + padding-top: 0.1rem; + color: var(--kbd-text-color); + background-color: var(--kbd-bg-color); + border-radius: $radius-sm; + border: solid 1px var(--kbd-wrap-color); + box-shadow: inset 0 -2px 0 var(--kbd-wrap-color); +} + +hr { + border-color: var(--main-border-color); + opacity: 1; +} + +footer { + background-color: var(--main-bg); + height: $footer-height; + border-top: 1px solid var(--main-border-color); + + @extend %text-xs; + + a { + @extend %text-highlight; + + &:hover { + @extend %link-hover; + } + } + + em { + @extend %text-highlight; + } + + p { + text-align: center; + margin-bottom: 0; + } +} + +/* fontawesome icons */ +i { + &.far, + &.fas { + @extend %no-cursor; + } +} + +/* --- Panels --- */ + +.access { + top: 2rem; + transition: top 0.2s ease-in-out; + margin-top: 3rem; + margin-bottom: 4rem; + + &:only-child { + position: -webkit-sticky; + position: sticky; + } + + > section { + padding-left: 1rem; + border-left: 1px solid var(--main-border-color); + + &:not(:last-child) { + margin-bottom: 4rem; + } + } + + .content { + font-size: 0.9rem; + } +} + +#panel-wrapper { + /* the headings */ + .panel-heading { + font-family: inherit; + line-height: inherit; + + @include label(inherit); + } + + .post-tag { + line-height: 1.05rem; + font-size: 0.85rem; + border-radius: 0.8rem; + padding: 0.3rem 0.5rem; + margin: 0 0.35rem 0.5rem 0; + + &:hover { + transition: all 0.3s ease-in; + } + } +} + +#access-lastmod { + a { + color: inherit; + + &:hover { + @extend %link-hover; + } + + @extend %no-bottom-border; + } +} + +.footnotes > ol { + padding-left: 2rem; + margin-top: 0.5rem; + + > li { + &:not(:last-child) { + margin-bottom: 0.3rem; + } + + @extend %sup-fn-target; + + > p { + margin-left: 0.25em; + margin-top: 0; + margin-bottom: 0; + } + } +} + +.footnote { + @at-root a#{&} { + @include ml-mr(1px); + @include pl-pr(2px); + + border-bottom-style: none !important; + } +} + +sup { + @extend %sup-fn-target; +} + +.reversefootnote { + @at-root a#{&} { + font-size: 0.6rem; + line-height: 1; + position: relative; + bottom: 0.25em; + margin-left: 0.25em; + border-bottom-style: none !important; + } +} + +/* --- Begin of Markdown table style --- */ + +/* it will be created by Liquid */ +.table-wrapper { + overflow-x: auto; + margin-bottom: 1.5rem; + + > table { + min-width: 100%; + overflow-x: auto; + border-spacing: 0; + + thead { + border-bottom: solid 2px rgba(210, 215, 217, 0.75); + + th { + @extend %table-cell; + } + } + + tbody { + tr { + border-bottom: 1px solid var(--tb-border-color); + + &:nth-child(2n) { + background-color: var(--tb-even-bg); + } + + &:nth-child(2n + 1) { + background-color: var(--tb-odd-bg); + } + + td { + @extend %table-cell; + } + } + } /* tbody */ + } /* table */ +} + +/* --- post --- */ + +.preview-img { + width: 100%; + height: 100%; + overflow: hidden; + + @extend %rounded; + + &:not(.no-bg) { + background: var(--img-bg); + } + + img { + height: 100%; + -o-object-fit: cover; + object-fit: cover; + + @extend %rounded; + + @at-root #post-list & { + width: 100%; + } + } +} + +.post-preview { + @extend %rounded; + + border: 0; + background: var(--card-bg); + box-shadow: var(--card-shadow); + + &::before { + @extend %rounded; + + content: ''; + width: 100%; + height: 100%; + position: absolute; + background-color: var(--card-hovor-bg); + opacity: 0; + transition: opacity 0.35s ease-in-out; + } + + &:hover { + &::before { + opacity: 0.3; + } + } +} + +main { + line-height: 1.75; + + h1 { + margin-top: 2rem; + } + + p { + > a.popup { + &:not(.normal):not(.left):not(.right) { + @include align-center; + } + } + } + + .categories, + #tags, + #archives { + a:not(:hover) { + @extend %no-bottom-border; + } + } +} + +.post-meta { + @extend %text-sm; + + a { + &:not([class]):hover { + @extend %link-hover; + } + } + + em { + @extend %normal-font-style; + } +} + +.content { + font-size: 1.08rem; + margin-top: 2rem; + overflow-wrap: break-word; + + a { + &.popup { + @extend %no-cursor; + @extend %img-caption; + @include mt-mb(0.5rem); + + cursor: zoom-in; + } + + &:not(.img-link) { + @extend %link-underline; + + &:hover { + @extend %link-hover; + } + } + } + + ol, + ul { + &:not([class]), + &.task-list { + -webkit-padding-start: 1.75rem; + padding-inline-start: 1.75rem; + + li { + margin: 0.25rem 0; + padding-left: 0.25rem; + } + + ol, + ul { + -webkit-padding-start: 1.25rem; + padding-inline-start: 1.25rem; + margin: 0.5rem 0; + } + } + } + + ul.task-list { + -webkit-padding-start: 1.25rem; + padding-inline-start: 1.25rem; + + li { + list-style-type: none; + padding-left: 0; + + /* checkbox icon */ + > i { + width: 2rem; + margin-left: -1.25rem; + color: var(--checkbox-color); + + &.checked { + color: var(--checkbox-checked-color); + } + } + + ul { + -webkit-padding-start: 1.75rem; + padding-inline-start: 1.75rem; + } + } + + input[type='checkbox'] { + margin: 0 0.5rem 0.2rem -1.3rem; + vertical-align: middle; + } + } /* ul */ + + dl > dd { + margin-left: 1rem; + } + + ::marker { + color: var(--text-muted-color); + } +} /* .content */ + +.tag:hover { + @extend %tag-hover; +} + +.post-tag { + display: inline-block; + min-width: 2rem; + text-align: center; + border-radius: 0.5rem; + border: 1px solid var(--btn-border-color); + padding: 0 0.4rem; + color: var(--text-muted-color); + line-height: 1.3rem; + + &:not(:last-child) { + margin-right: 0.2rem; + } +} + +.rounded-10 { + border-radius: 10px !important; +} + +.img-link { + color: transparent; + display: inline-flex; +} + +.shimmer { + overflow: hidden; + position: relative; + background: var(--img-bg); + + &::before { + content: ''; + position: absolute; + background: var(--shimmer-bg); + height: 100%; + width: 100%; + -webkit-animation: shimmer 1.3s infinite; + animation: shimmer 1.3s infinite; + } + + @-webkit-keyframes shimmer { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } + } + + @keyframes shimmer { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } + } +} + +.embed-video { + width: 100%; + height: 100%; + margin-bottom: 1rem; + aspect-ratio: 16 / 9; + + @extend %rounded; + + &.twitch { + aspect-ratio: 310 / 189; + } + + &.file { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + margin: auto; + margin-bottom: 0; + } + + @extend %img-caption; +} + +.embed-audio { + width: 100%; + display: block; + + @extend %img-caption; +} + +/* --- buttons --- */ +.btn-lang { + border: 1px solid !important; + padding: 1px 3px; + border-radius: 3px; + color: var(--link-color); + + &:focus { + box-shadow: none; + } +} + +/* --- Effects classes --- */ + +.flex-grow-1 { + flex-grow: 1 !important; +} + +.btn-box-shadow { + box-shadow: var(--card-shadow); +} + +/* overwrite bootstrap muted */ +.text-muted { + color: var(--text-muted-color) !important; +} + +/* Overwrite bootstrap tooltip */ +.tooltip-inner { + font-size: 0.7rem; + max-width: 220px; + text-align: left; +} + +/* Overwrite bootstrap outline button */ +.btn.btn-outline-primary { + &:not(.disabled):hover { + border-color: #007bff !important; + } +} + +.disabled { + color: rgb(206, 196, 196); + pointer-events: auto; + cursor: not-allowed; +} + +.hide-border-bottom { + border-bottom: none !important; +} + +.input-focus { + box-shadow: none; + border-color: var(--input-focus-border-color) !important; + background: center !important; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out; +} + +.left { + float: left; + margin: 0.75rem 1rem 1rem 0; +} + +.right { + float: right; + margin: 0.75rem 0 1rem 1rem; +} + +/* --- Overriding --- */ + +/* mermaid */ +.mermaid { + text-align: center; +} + +/* MathJax */ +mjx-container { + overflow-y: hidden; + min-width: auto !important; +} + +/* --- sidebar layout --- */ + +$sidebar-display: 'sidebar-display'; +$btn-border-width: 3px; +$btn-mb: 0.5rem; + +#sidebar { + @include pl-pr(0); + + position: fixed; + top: 0; + left: 0; + height: 100%; + overflow-y: auto; + width: $sidebar-width; + z-index: 99; + background: var(--sidebar-bg); + border-right: 1px solid var(--sidebar-border-color); + + /* Hide scrollbar for IE, Edge and Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + + /* Hide scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + display: none; + } + + %sidebar-link-hover { + &:hover { + color: var(--sidebar-active-color); + } + } + + a { + @extend %sidebar-links; + } + + #avatar { + display: block; + width: 7rem; + height: 7rem; + overflow: hidden; + box-shadow: var(--avatar-border-color) 0 0 0 2px; + transform: translateZ(0); /* fixed the zoom in Safari */ + + img { + transition: transform 0.5s; + + &:hover { + transform: scale(1.2); + } + } + } + + .profile-wrapper { + @include mt-mb(2.5rem); + @extend %clickable-transition; + + padding-left: 2.5rem; + padding-right: 1.25rem; + width: 100%; + } + + .site-title { + font-family: inherit; + font-weight: 900; + font-size: 1.75rem; + line-height: 1.2; + letter-spacing: 0.25px; + margin-top: 1.25rem; + margin-bottom: 0.5rem; + + a { + @extend %clickable-transition; + @extend %sidebar-link-hover; + + color: var(--site-title-color); + } + } + + .site-subtitle { + font-size: 95%; + color: var(--site-subtitle-color); + margin-top: 0.25rem; + word-spacing: 1px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + ul { + margin-bottom: 2rem; + + li.nav-item { + opacity: 0.9; + width: 100%; + padding-left: 1.5rem; + padding-right: 1.5rem; + + a.nav-link { + @include pt-pb(0.6rem); + + display: flex; + align-items: center; + border-radius: 0.75rem; + font-weight: 600; + + &:hover { + background-color: var(--sidebar-hover-bg); + } + + i { + font-size: 95%; + opacity: 0.8; + margin-right: 1.5rem; + } + + span { + font-size: 90%; + letter-spacing: 0.2px; + } + } + + &.active { + .nav-link { + color: var(--sidebar-active-color); + background-color: var(--sidebar-hover-bg); + + span { + opacity: 1; + } + } + } + + &:not(:first-child) { + margin-top: 0.25rem; + } + } + } + + .sidebar-bottom { + padding-left: 2rem; + padding-right: 1rem; + margin-bottom: 1.5rem; + + $btn-size: 1.75rem; + + %button { + width: $btn-size; + height: $btn-size; + margin-bottom: $btn-mb; // multi line gap + border-radius: 50%; + color: var(--sidebar-btn-color); + background-color: var(--sidebar-btn-bg); + text-align: center; + display: flex; + align-items: center; + justify-content: center; + + &:not(:focus-visible) { + box-shadow: var(--sidebar-border-color) 0 0 0 1px; + } + + &:hover { + background-color: var(--sidebar-hover-bg); + } + } + + a { + @extend %button; + @extend %sidebar-link-hover; + @extend %clickable-transition; + + &:not(:last-child) { + margin-right: $sb-btn-gap; + } + } + + i { + line-height: $btn-size; + } + + #mode-toggle { + @extend %button; + @extend %sidebar-links; + @extend %sidebar-link-hover; + } + + .icon-border { + @extend %no-cursor; + @include ml-mr(calc(($sb-btn-gap - $btn-border-width) / 2)); + + background-color: var(--sidebar-btn-color); + content: ''; + width: $btn-border-width; + height: $btn-border-width; + border-radius: 50%; + margin-bottom: $btn-mb; + } + } /* .sidebar-bottom */ +} /* #sidebar */ + +@media (hover: hover) { + #sidebar ul > li:last-child::after { + transition: top 0.5s ease; + } + + .nav-link { + transition: background-color 0.3s ease-in-out; + } + + .post-preview { + transition: background-color 0.35s ease-in-out; + } +} + +#search-result-wrapper { + display: none; + height: 100%; + width: 100%; + overflow: auto; + + .content { + margin-top: 2rem; + } +} + +/* --- top-bar --- */ + +#topbar-wrapper { + height: $topbar-height; + background-color: var(--topbar-bg); +} + +#topbar { + button i { + color: #999999; + } + + #breadcrumb { + font-size: 1rem; + color: var(--text-muted-color); + padding-left: 0.5rem; + + a:hover { + @extend %link-hover; + } + + span { + &:not(:last-child) { + &::after { + content: '›'; + padding: 0 0.3rem; + } + } + } + } +} /* #topbar */ + +::-webkit-input-placeholder { + @include placeholder; +} + +::-moz-placeholder { + @include placeholder; +} + +:-ms-input-placeholder { + @include placeholder; +} + +::-ms-input-placeholder { + @include placeholder; +} + +::placeholder { + @include placeholder; +} + +:focus::-webkit-input-placeholder { + @include placeholder-focus; +} + +:focus::-moz-placeholder { + @include placeholder-focus; +} + +:focus:-ms-input-placeholder { + @include placeholder-focus; +} + +:focus::-ms-input-placeholder { + @include placeholder-focus; +} + +:focus::placeholder { + @include placeholder-focus; +} + +search { + display: flex; + width: 100%; + border-radius: 1rem; + border: 1px solid var(--search-border-color); + background: var(--main-bg); + padding: 0 0.5rem; + + i { + z-index: 2; + font-size: 0.9rem; + color: var(--search-icon-color); + } +} + +#sidebar-trigger, +#search-trigger { + display: none; +} + +/* 'Cancel' link */ +#search-cancel { + color: var(--link-color); + display: none; + white-space: nowrap; + + @extend %cursor-pointer; +} + +#search-input { + background: center; + border: 0; + border-radius: 0; + padding: 0.18rem 0.3rem; + color: var(--text-color); + height: auto; + + &:focus { + box-shadow: none; + } +} + +#search-hints { + padding: 0 1rem; + + h4 { + margin-bottom: 1.5rem; + } + + .post-tag { + display: inline-block; + line-height: 1rem; + font-size: 1rem; + background: var(--search-tag-bg); + border: none; + padding: 0.5rem; + margin: 0 1.25rem 1rem 0; + + &::before { + content: '#'; + color: var(--text-muted-color); + padding-right: 0.2rem; + } + + @extend %link-color; + } +} + +#search-results { + padding-bottom: 3rem; + + a { + font-size: 1.4rem; + line-height: 2.5rem; + + &:hover { + @extend %link-hover; + } + + @extend %link-color; + @extend %no-bottom-border; + @extend %heading; + } + + > article { + width: 100%; + + &:not(:last-child) { + margin-bottom: 1rem; + } + + /* icons */ + i { + color: #818182; + margin-right: 0.15rem; + font-size: 80%; + } + + > p { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + } + } +} /* #search-results */ + +#topbar-title { + display: none; + font-size: 1.1rem; + font-weight: 600; + font-family: sans-serif; + color: var(--topbar-text-color); + text-align: center; + width: 70%; + overflow: hidden; + text-overflow: ellipsis; + word-break: keep-all; + white-space: nowrap; +} + +#mask { + display: none; + position: fixed; + inset: 0 0 0 0; + height: 100%; + width: 100%; + z-index: 1; + + @at-root [#{$sidebar-display}] & { + display: block !important; + } +} + +/* --- basic wrappers --- */ + +#main-wrapper { + position: relative; + + @include pl-pr(0); + + > .container { + min-height: 100vh; + } +} + +#topbar-wrapper.row, +#main-wrapper > .container > .row, +#search-result-wrapper > .row { + @include ml-mr(0); +} + +#tail-wrapper { + > :not(script) { + margin-top: 3rem; + } +} + +/* --- button back-to-top --- */ + +#back-to-top { + visibility: hidden; + opacity: 0; + z-index: 1; + cursor: pointer; + position: fixed; + right: 1rem; + bottom: calc($footer-height-large - $back2top-size / 2); + background: var(--button-bg); + color: var(--btn-backtotop-color); + padding: 0; + width: $back2top-size; + height: $back2top-size; + border-radius: 50%; + border: 1px solid var(--btn-backtotop-border-color); + transition: opacity 0.5s ease-in-out, transform 0.2s ease-out; + + &:hover { + transform: translate3d(0, -5px, 0); + -webkit-transform: translate3d(0, -5px, 0); + } + + i { + line-height: $back2top-size; + position: relative; + bottom: 2px; + } + + &.show { + opacity: 1; + visibility: visible; + } +} + +#notification { + @-webkit-keyframes popup { + from { + opacity: 0; + bottom: 0; + } + } + + @keyframes popup { + from { + opacity: 0; + bottom: 0; + } + } + + .toast-header { + background: none; + border-bottom: none; + color: inherit; + } + + .toast-body { + font-family: Lato, sans-serif; + line-height: 1.25rem; + + button { + font-size: 90%; + min-width: 4rem; + } + } + + &.toast { + &.show { + display: block; + min-width: 20rem; + border-radius: 0.5rem; + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + background-color: rgba(255, 255, 255, 0.5); + color: #1b1b1eba; + position: fixed; + left: 50%; + bottom: 20%; + transform: translateX(-50%); + -webkit-animation: popup 0.8s; + animation: popup 0.8s; + } + } +} + +/* + Responsive Design: + + {sidebar, content, panel} >= 1200px screen width + {sidebar, content} >= 850px screen width + {content} <= 849px screen width + +*/ + +@media all and (max-width: 576px) { + main { + .content { + > blockquote[class^='prompt-'] { + @include ml-mr(-1rem); + + border-radius: 0; + max-width: none; + } + } + } + + #avatar { + width: 5rem; + height: 5rem; + } +} + +@media all and (max-width: 768px) { + %full-width { + max-width: 100%; + } + + #topbar { + @extend %full-width; + } + + #main-wrapper > .container { + @extend %full-width; + @include pl-pr(0); + } +} + +/* hide sidebar and panel */ +@media all and (max-width: 849px) { + @mixin slide($append: null) { + $basic: transform 0.4s ease; + + @if $append { + transition: $basic, $append; + } @else { + transition: $basic; + } + } + + footer { + @include slide; + + height: $footer-height-large; + padding: 1.5rem 0; + } + + [#{$sidebar-display}] { + #sidebar { + transform: translateX(0); + } + + #main-wrapper { + transform: translateX($sidebar-width); + } + + #back-to-top { + visibility: hidden; + } + } + + #sidebar { + @include slide; + + transform: translateX(-$sidebar-width); /* hide */ + -webkit-transform: translateX(-$sidebar-width); + } + + #main-wrapper { + @include slide; + } + + #topbar, + #main-wrapper > .container { + max-width: 100%; + } + + #search-result-wrapper { + width: 100%; + } + + #breadcrumb, + search { + display: none; + } + + #topbar-wrapper { + @include slide(top 0.2s ease); + + left: 0; + } + + main, + #panel-wrapper { + margin-top: 0; + } + + #topbar-title, + #sidebar-trigger, + #search-trigger { + display: block; + } + + #search-result-wrapper .content { + letter-spacing: 0; + } + + #tags { + justify-content: center !important; + } + + h1.dynamic-title { + display: none; + + ~ .content { + margin-top: 2.5rem; + } + } +} /* max-width: 849px */ + +/* Sidebar is visible */ +@media all and (min-width: 850px) { + /* Solved jumping scrollbar */ + html { + overflow-y: scroll; + } + + #main-wrapper { + margin-left: $sidebar-width; + } + + #sidebar { + .profile-wrapper { + margin-top: 3rem; + } + } + + #search-hints { + display: none; + } + + search { + max-width: $search-max-width; + } + + #search-result-wrapper { + max-width: $main-content-max-width; + justify-content: start !important; + } + + main { + h1 { + margin-top: 3rem; + } + } + + div.content .table-wrapper > table { + min-width: 70%; + } + + /* button 'back-to-Top' position */ + #back-to-top { + right: 5%; + bottom: calc($footer-height - $back2top-size / 2); + } + + #topbar-title { + text-align: left; + } +} + +/* Pad horizontal */ +@media all and (min-width: 992px) and (max-width: 1199px) { + #main-wrapper > .container .col-lg-11 { + flex: 0 0 96%; + max-width: 96%; + } +} + +/* Compact icons in sidebar & panel hidden */ +@media all and (min-width: 850px) and (max-width: 1199px) { + #search-results > div { + max-width: 700px; + } + + #breadcrumb { + width: 65%; + overflow: hidden; + text-overflow: ellipsis; + word-break: keep-all; + white-space: nowrap; + } +} + +/* panel hidden */ +@media all and (max-width: 1199px) { + #panel-wrapper { + display: none; + } + + #main-wrapper > .container > div.row { + justify-content: center !important; + } +} + +/* --- desktop mode, both sidebar and panel are visible --- */ + +@media all and (min-width: 1200px) { + search { + margin-right: 4rem; + } + + #search-input { + transition: all 0.3s ease-in-out; + } + + #search-results > article { + width: 45%; + + &:nth-child(odd) { + margin-right: 1.5rem; + } + + &:nth-child(even) { + margin-left: 1.5rem; + } + + &:last-child:nth-child(odd) { + position: relative; + right: 24.3%; + } + } + + .content { + font-size: 1.03rem; + } +} + +@media all and (min-width: 1400px) { + #back-to-top { + right: calc((100vw - $sidebar-width - 1140px) / 2 + 3rem); + } +} + +@media all and (min-width: 1650px) { + $icon-gap: 1rem; + + #main-wrapper { + margin-left: $sidebar-width-large; + } + + #topbar-wrapper { + left: $sidebar-width-large; + } + + search { + margin-right: calc( + $main-content-max-width / 4 - $search-max-width - 0.75rem + ); + } + + #main-wrapper > .container { + max-width: $main-content-max-width; + padding-left: 1.75rem !important; + padding-right: 1.75rem !important; + } + + main.col-12, + #tail-wrapper { + padding-right: 4.5rem !important; + } + + #back-to-top { + right: calc( + (100vw - $sidebar-width-large - $main-content-max-width) / 2 + 2rem + ); + } + + #sidebar { + width: $sidebar-width-large; + + .profile-wrapper { + margin-top: 3.5rem; + margin-bottom: 2.5rem; + padding-left: 3.5rem; + } + + ul { + li.nav-item { + @include pl-pr(2.75rem); + } + } + + .sidebar-bottom { + padding-left: 2.75rem; + margin-bottom: 1.75rem; + + a:not(:last-child) { + margin-right: $sb-btn-gap-lg; + } + + .icon-border { + @include ml-mr(calc(($sb-btn-gap-lg - $btn-border-width) / 2)); + } + } + } +} /* min-width: 1650px */ diff --git a/_sass/addon/module.scss b/_sass/addon/module.scss new file mode 100644 index 0000000..42db4e2 --- /dev/null +++ b/_sass/addon/module.scss @@ -0,0 +1,193 @@ +/* +* Mainly scss modules, only imported to `assets/css/main.scss` +*/ + +/* ---------- scss placeholder --------- */ + +%heading { + color: var(--heading-color); + font-weight: 400; + font-family: $font-family-heading; +} + +%anchor { + .anchor { + font-size: 80%; + } + + @media (hover: hover) { + .anchor { + visibility: hidden; + opacity: 0; + transition: opacity 0.25s ease-in, visibility 0s ease-in 0.25s; + } + + &:hover { + .anchor { + visibility: visible; + opacity: 1; + transition: opacity 0.25s ease-in, visibility 0s ease-in 0s; + } + } + } +} + +%tag-hover { + background: var(--tag-hover); + transition: background 0.35s ease-in-out; +} + +%table-cell { + padding: 0.4rem 1rem; + font-size: 95%; + white-space: nowrap; +} + +%link-hover { + color: #d2603a !important; + border-bottom: 1px solid #d2603a; + text-decoration: none; +} + +%link-color { + color: var(--link-color); +} + +%link-underline { + border-bottom: 1px solid var(--link-underline-color); +} + +%clickable-transition { + transition: all 0.3s ease-in-out; +} + +%no-cursor { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +%no-bottom-border { + border-bottom: none; +} + +%cursor-pointer { + cursor: pointer; +} + +%normal-font-style { + font-style: normal; +} + +%rounded { + border-radius: $radius-lg; +} + +%img-caption { + + em { + display: block; + text-align: center; + font-style: normal; + font-size: 80%; + padding: 0; + color: #6d6c6c; + } +} + +%sidebar-links { + color: var(--sidebar-muted-color); + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +%text-clip { + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +%text-highlight { + color: var(--text-muted-highlight-color); + font-weight: 600; +} + +%text-sm { + font-size: 0.85rem; +} + +%text-xs { + font-size: 0.8rem; +} + +%sup-fn-target { + &:target { + background-color: var(--footnote-target-bg); + width: -moz-fit-content; + width: -webkit-fit-content; + width: fit-content; + transition: background-color 1.75s ease-in-out; + } +} + +/* ---------- scss mixin --------- */ + +@mixin mt-mb($value) { + margin-top: $value; + margin-bottom: $value; +} + +@mixin ml-mr($value) { + margin-left: $value; + margin-right: $value; +} + +@mixin pt-pb($val) { + padding-top: $val; + padding-bottom: $val; +} + +@mixin pl-pr($val) { + padding-left: $val; + padding-right: $val; +} + +@mixin placeholder { + color: var(--text-muted-color) !important; +} + +@mixin placeholder-focus { + opacity: 0.6; +} + +@mixin label($font-size: 1rem, $font-weight: 600, $color: var(--label-color)) { + color: $color; + font-size: $font-size; + font-weight: $font-weight; +} + +@mixin align-center { + position: relative; + left: 50%; + transform: translateX(-50%); +} + +@mixin prompt($type, $fa-content, $fa-style: 'solid', $rotate: 0) { + &.prompt-#{$type} { + background-color: var(--prompt-#{$type}-bg); + + &::before { + content: $fa-content; + color: var(--prompt-#{$type}-icon-color); + font: var(--fa-font-#{$fa-style}); + + @if $rotate != 0 { + transform: rotate(#{$rotate}deg); + } + } + } +} diff --git a/_sass/addon/syntax.scss b/_sass/addon/syntax.scss new file mode 100644 index 0000000..6bd7b40 --- /dev/null +++ b/_sass/addon/syntax.scss @@ -0,0 +1,292 @@ +/* +* The syntax highlight. +*/ + +@import 'colors/syntax-light'; +@import 'colors/syntax-dark'; + +html { + @media (prefers-color-scheme: light) { + &:not([data-mode]), + &[data-mode='light'] { + @include light-syntax; + } + + &[data-mode='dark'] { + @include dark-syntax; + } + } + + @media (prefers-color-scheme: dark) { + &:not([data-mode]), + &[data-mode='dark'] { + @include dark-syntax; + } + + &[data-mode='light'] { + @include light-syntax; + } + } +} + +/* -- code snippets -- */ + +%code-snippet-bg { + background-color: var(--highlight-bg-color); +} + +%code-snippet-padding { + padding-left: 1rem; + padding-right: 1.5rem; +} + +.highlighter-rouge { + color: var(--highlighter-rouge-color); + margin-top: 0.5rem; + margin-bottom: 1.2em; /* Override BS Inline-code style */ +} + +.highlight { + @extend %rounded; + @extend %code-snippet-bg; + + overflow: auto; + padding-bottom: 0.75rem; + + @at-root figure#{&} { + @extend %code-snippet-bg; + } + + pre { + margin-bottom: 0; + font-size: $code-font-size; + line-height: 1.4rem; + word-wrap: normal; /* Fixed Safari overflow-x */ + } + + table { + td { + &:first-child { + display: inline-block; + margin-left: 1rem; + margin-right: 0.75rem; + } + + &:last-child { + padding-right: 2rem !important; + } + + pre { + overflow: visible; /* Fixed iOS safari overflow-x */ + word-break: normal; /* Fixed iOS safari linenos code break */ + } + } + } + + .lineno { + text-align: right; + color: var(--highlight-lineno-color); + -webkit-user-select: none; + -moz-user-select: none; + -o-user-select: none; + -ms-user-select: none; + user-select: none; + } +} /* .highlight */ + +code { + -webkit-hyphens: none; + -ms-hyphens: none; + hyphens: none; + color: var(--code-color); + + &.highlighter-rouge { + font-size: $code-font-size; + padding: 3px 5px; + word-break: break-word; + border-radius: $radius-sm; + background-color: var(--inline-code-bg); + } + + &.filepath { + background-color: inherit; + color: var(--filepath-text-color); + font-weight: 600; + padding: 0; + } + + a > &.highlighter-rouge { + padding-bottom: 0; /* show link's underlinke */ + color: inherit; + } + + a:hover > &.highlighter-rouge { + border-bottom: none; + } + + blockquote & { + color: inherit; + } +} + +td.rouge-code { + @extend %code-snippet-padding; + + /* + Prevent some browser extends from + changing the URL string of code block. + */ + a { + color: inherit !important; + border-bottom: none !important; + pointer-events: none; + } +} + +div[class^='language-'] { + @extend %rounded; + @extend %code-snippet-bg; + + box-shadow: var(--language-border-color) 0 0 0 1px; + + .content > & { + @include ml-mr(-1rem); + + border-radius: 0; + } + + .highlight { + border-top-left-radius: 0; + border-top-right-radius: 0; + } +} + +/* Hide line numbers for default, console, and terminal code snippets */ +div { + &.nolineno, + &.language-plaintext, + &.language-console, + &.language-terminal { + td:first-child { + padding: 0 !important; + margin-right: 0; + + .lineno { + display: none; + } + } + } +} + +.code-header { + @extend %no-cursor; + + display: flex; + justify-content: space-between; + align-items: center; + height: $code-header-height; + margin-left: 0.75rem; + margin-right: 0.25rem; + + /* the label block */ + span { + line-height: $code-header-height; + + /* label icon */ + i { + font-size: 1rem; + width: $code-icon-width; + color: var(--code-header-icon-color); + + &.small { + font-size: 70%; + } + } + + @at-root [file] #{&} > i { + position: relative; + top: 1px; /* center the file icon */ + } + + /* label text */ + &::after { + content: attr(data-label-text); + font-size: 0.85rem; + font-weight: 600; + color: var(--code-header-text-color); + } + } + + /* clipboard */ + button { + @extend %cursor-pointer; + @extend %rounded; + + border: 1px solid transparent; + height: $code-header-height; + width: $code-header-height; + padding: 0; + background-color: inherit; + + i { + color: var(--code-header-icon-color); + } + + &[timeout] { + &:hover { + border-color: var(--clipboard-checked-color); + } + + i { + color: var(--clipboard-checked-color); + } + } + + &:focus { + outline: none; + } + + &:not([timeout]):hover { + background-color: rgba(128, 128, 128, 0.37); + + i { + color: white; + } + } + } +} + +@media all and (min-width: 576px) { + div[class^='language-'] { + .content > & { + @include ml-mr(0); + + border-radius: $radius-lg; + } + + .code-header { + @include ml-mr(0); + + $dot-margin: 1rem; + + &::before { + content: ''; + display: inline-block; + margin-left: $dot-margin; + width: $code-dot-size; + height: $code-dot-size; + border-radius: 50%; + background-color: var(--code-header-muted-color); + box-shadow: ($code-dot-size + $code-dot-gap) 0 0 + var(--code-header-muted-color), + ($code-dot-size + $code-dot-gap) * 2 0 0 + var(--code-header-muted-color); + } + + span { + // center the text of label + margin-left: calc(($dot-margin + $code-dot-size) / 2 * -1); + } + } + } +} diff --git a/_sass/addon/variables.scss b/_sass/addon/variables.scss new file mode 100644 index 0000000..1d51cb1 --- /dev/null +++ b/_sass/addon/variables.scss @@ -0,0 +1,34 @@ +/* + * The SCSS variables + */ + +/* sidebar */ + +$sidebar-width: 260px !default; /* the basic width */ +$sidebar-width-large: 300px !default; /* screen width: >= 1650px */ +$sb-btn-gap: 0.8rem !default; +$sb-btn-gap-lg: 1rem !default; + +/* other framework sizes */ + +$topbar-height: 3rem !default; +$search-max-width: 200px !default; +$footer-height: 5rem !default; +$footer-height-large: 6rem !default; /* screen width: < 850px */ +$main-content-max-width: 1250px !default; +$radius-sm: 6px !default; +$radius-lg: 10px !default; +$back2top-size: 2.75rem !default; + +/* syntax highlight */ + +$code-font-size: 0.85rem !default; +$code-header-height: 2.25rem !default; +$code-dot-size: 0.75rem !default; +$code-dot-gap: 0.5rem !default; +$code-icon-width: 1.75rem !default; + +/* fonts */ + +$font-family-base: 'Source Sans Pro', 'Microsoft Yahei', sans-serif !default; +$font-family-heading: Lato, 'Microsoft Yahei', sans-serif !default; diff --git a/_sass/colors/syntax-dark.scss b/_sass/colors/syntax-dark.scss new file mode 100644 index 0000000..eb92204 --- /dev/null +++ b/_sass/colors/syntax-dark.scss @@ -0,0 +1,164 @@ +/* + * The syntax dark mode styles. + */ + +@mixin dark-syntax { + --language-border-color: #2d2d2d; + --highlight-bg-color: #151515; + --highlighter-rouge-color: #c9def1; + --highlight-lineno-color: #808080; + --inline-code-bg: rgba(255, 255, 255, 0.05); + --code-color: #b0b0b0; + --code-header-text-color: #6a6a6a; + --code-header-muted-color: #353535; + --code-header-icon-color: #565656; + --clipboard-checked-color: #2bcc2b; + --filepath-text-color: #cacaca; + + .highlight .gp { + color: #87939d; + } + + /* --- Syntax highlight theme from `rougify style base16.dark` --- */ + + .highlight table td { + padding: 5px; + } + + .highlight table pre { + margin: 0; + } + + .highlight, + .highlight .w { + color: #d0d0d0; + background-color: #151515; + } + + .highlight .err { + color: #151515; + background-color: #ac4142; + } + + .highlight .c, + .highlight .ch, + .highlight .cd, + .highlight .cm, + .highlight .cpf, + .highlight .c1, + .highlight .cs { + color: #848484; + } + + .highlight .cp { + color: #f4bf75; + } + + .highlight .nt { + color: #f4bf75; + } + + .highlight .o, + .highlight .ow { + color: #d0d0d0; + } + + .highlight .p, + .highlight .pi { + color: #d0d0d0; + } + + .highlight .gi { + color: #90a959; + } + + .highlight .gd { + color: #f08a8b; + background-color: #320000; + } + + .highlight .gh { + color: #6a9fb5; + background-color: #151515; + font-weight: bold; + } + + .highlight .k, + .highlight .kn, + .highlight .kp, + .highlight .kr, + .highlight .kv { + color: #aa759f; + } + + .highlight .kc { + color: #d28445; + } + + .highlight .kt { + color: #d28445; + } + + .highlight .kd { + color: #d28445; + } + + .highlight .s, + .highlight .sb, + .highlight .sc, + .highlight .dl, + .highlight .sd, + .highlight .s2, + .highlight .sh, + .highlight .sx, + .highlight .s1 { + color: #90a959; + } + + .highlight .sa { + color: #aa759f; + } + + .highlight .sr { + color: #75b5aa; + } + + .highlight .si { + color: #b76d45; + } + + .highlight .se { + color: #b76d45; + } + + .highlight .nn { + color: #f4bf75; + } + + .highlight .nc { + color: #f4bf75; + } + + .highlight .no { + color: #f4bf75; + } + + .highlight .na { + color: #6a9fb5; + } + + .highlight .m, + .highlight .mb, + .highlight .mf, + .highlight .mh, + .highlight .mi, + .highlight .il, + .highlight .mo, + .highlight .mx { + color: #90a959; + } + + .highlight .ss { + color: #90a959; + } +} diff --git a/_sass/colors/syntax-light.scss b/_sass/colors/syntax-light.scss new file mode 100644 index 0000000..76aa669 --- /dev/null +++ b/_sass/colors/syntax-light.scss @@ -0,0 +1,210 @@ +/* + * The syntax light mode code snippet colors. + */ + +@mixin light-syntax { + /* --- custom light colors --- */ + --language-border-color: #ececec; + --highlight-bg-color: #f6f8fa; + --highlighter-rouge-color: #3f596f; + --highlight-lineno-color: #9e9e9e; + --inline-code-bg: rgba(25, 25, 28, 0.05); + --code-color: #3a3a3a; + --code-header-text-color: #a3a3a3; + --code-header-muted-color: #e5e5e5; + --code-header-icon-color: #c9c8c8; + --clipboard-checked-color: #43c743; + + /* --- Syntax highlight theme from `rougify style github` --- */ + + .highlight table td { + padding: 5px; + } + + .highlight table pre { + margin: 0; + } + + .highlight, + .highlight .w { + color: #24292f; + background-color: #f6f8fa; + } + + .highlight .k, + .highlight .kd, + .highlight .kn, + .highlight .kp, + .highlight .kr, + .highlight .kt, + .highlight .kv { + color: #cf222e; + } + + .highlight .gr { + color: #f6f8fa; + } + + .highlight .gd { + color: #82071e; + background-color: #ffebe9; + } + + .highlight .nb { + color: #953800; + } + + .highlight .nc { + color: #953800; + } + + .highlight .no { + color: #953800; + } + + .highlight .nn { + color: #953800; + } + + .highlight .sr { + color: #116329; + } + + .highlight .na { + color: #116329; + } + + .highlight .nt { + color: #116329; + } + + .highlight .gi { + color: #116329; + background-color: #dafbe1; + } + + .highlight .kc { + color: #0550ae; + } + + .highlight .l, + .highlight .ld, + .highlight .m, + .highlight .mb, + .highlight .mf, + .highlight .mh, + .highlight .mi, + .highlight .il, + .highlight .mo, + .highlight .mx { + color: #0550ae; + } + + .highlight .sb { + color: #0550ae; + } + + .highlight .bp { + color: #0550ae; + } + + .highlight .ne { + color: #0550ae; + } + + .highlight .nl { + color: #0550ae; + } + + .highlight .py { + color: #0550ae; + } + + .highlight .nv, + .highlight .vc, + .highlight .vg, + .highlight .vi, + .highlight .vm { + color: #0550ae; + } + + .highlight .o, + .highlight .ow { + color: #0550ae; + } + + .highlight .gh { + color: #0550ae; + font-weight: bold; + } + + .highlight .gu { + color: #0550ae; + font-weight: bold; + } + + .highlight .s, + .highlight .sa, + .highlight .sc, + .highlight .dl, + .highlight .sd, + .highlight .s2, + .highlight .se, + .highlight .sh, + .highlight .sx, + .highlight .s1, + .highlight .ss { + color: #0a3069; + } + + .highlight .nd { + color: #8250df; + } + + .highlight .nf, + .highlight .fm { + color: #8250df; + } + + .highlight .err { + color: #f6f8fa; + background-color: #82071e; + } + + .highlight .c, + .highlight .ch, + .highlight .cd, + .highlight .cm, + .highlight .cp, + .highlight .cpf, + .highlight .c1, + .highlight .cs { + color: #68717a; + } + + .highlight .gl { + color: #68717a; + } + + .highlight .gt { + color: #68717a; + } + + .highlight .ni { + color: #24292f; + } + + .highlight .si { + color: #24292f; + } + + .highlight .ge { + color: #24292f; + font-style: italic; + } + + .highlight .gs { + color: #24292f; + font-weight: bold; + } +} /* light-syntax */ diff --git a/_sass/colors/typography-dark.scss b/_sass/colors/typography-dark.scss new file mode 100644 index 0000000..12427ec --- /dev/null +++ b/_sass/colors/typography-dark.scss @@ -0,0 +1,147 @@ +/* + * The main dark mode styles + */ + +@mixin dark-scheme { + /* Framework color */ + --main-bg: rgb(27, 27, 30); + --mask-bg: rgb(68, 69, 70); + --main-border-color: rgb(44, 45, 45); + + /* Common color */ + --text-color: rgb(175, 176, 177); + --text-muted-color: #868686; + --text-muted-highlight-color: #aeaeae; + --heading-color: #cccccc; + --label-color: #a7a7a7; + --blockquote-border-color: rgb(66, 66, 66); + --blockquote-text-color: #868686; + --link-color: rgb(138, 180, 248); + --link-underline-color: rgb(82, 108, 150); + --button-bg: #1e1e1e; + --btn-border-color: #2e2f31; + --btn-backtotop-color: var(--text-color); + --btn-backtotop-border-color: #212122; + --btn-box-shadow: var(--main-bg); + --card-header-bg: #292929; + --checkbox-color: rgb(118, 120, 121); + --checkbox-checked-color: var(--link-color); + --img-bg: radial-gradient(circle, rgb(22, 22, 24) 0%, rgb(32, 32, 32) 100%); + --shimmer-bg: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(58, 55, 55, 0.4) 50%, + rgba(255, 255, 255, 0) 100% + ); + + /* Sidebar */ + --site-title-color: #717070; + --site-subtitle-color: #868686; + --sidebar-bg: #1e1e1e; + --sidebar-border-color: #292929; + --sidebar-muted-color: #868686; + --sidebar-active-color: rgb(255, 255, 255, 0.95); + --sidebar-hover-bg: #262626; + --sidebar-btn-bg: #232328; + --sidebar-btn-color: #787878; + --avatar-border-color: rgb(206, 206, 206, 0.9); + + /* Topbar */ + --topbar-bg: rgb(27, 27, 30, 0.64); + --topbar-text-color: var(--text-color); + --search-border-color: rgb(55, 55, 55); + --search-icon-color: rgb(100, 102, 105); + --input-focus-border-color: rgb(112, 114, 115); + + /* Home page */ + --post-list-text-color: rgb(175, 176, 177); + --btn-patinator-text-color: var(--text-color); + --btn-paginator-hover-color: #2e2e2e; + + /* Posts */ + --toc-highlight: rgb(116, 178, 243); + --tag-hover: rgb(43, 56, 62); + --tb-odd-bg: #252526; /* odd rows of the posts' table */ + --tb-even-bg: rgb(31, 31, 34); /* even rows of the posts' table */ + --tb-border-color: var(--tb-odd-bg); + --footnote-target-bg: rgb(63, 81, 181); + --btn-share-color: #6c757d; + --btn-share-hover-color: #bfc1ca; + --card-bg: #1e1e1e; + --card-hovor-bg: #464d51; + --card-shadow: rgb(21, 21, 21, 0.72) 0 6px 18px 0, + rgb(137, 135, 135, 0.24) 0 0 0 1px; + --kbd-wrap-color: #6a6a6a; + --kbd-text-color: #d3d3d3; + --kbd-bg-color: #242424; + --prompt-text-color: rgb(216, 212, 212, 0.75); + --prompt-tip-bg: rgb(22, 60, 36, 0.64); + --prompt-tip-icon-color: rgb(15, 164, 15, 0.81); + --prompt-info-bg: rgb(7, 59, 104, 0.8); + --prompt-info-icon-color: #0075d1; + --prompt-warning-bg: rgb(90, 69, 3, 0.88); + --prompt-warning-icon-color: rgb(255, 165, 0, 0.8); + --prompt-danger-bg: rgb(86, 28, 8, 0.8); + --prompt-danger-icon-color: #cd0202; + + /* Tags */ + --tag-border: rgb(59, 79, 88); + --tag-shadow: rgb(32, 33, 33); + --dash-color: rgb(63, 65, 68); + --search-tag-bg: #292828; + + /* Categories */ + --categories-border: rgb(64, 66, 69, 0.5); + --categories-hover-bg: rgb(73, 75, 76); + --categories-icon-hover-color: white; + + /* Archive */ + --timeline-node-bg: rgb(150, 152, 156); + --timeline-color: rgb(63, 65, 68); + --timeline-year-dot-color: var(--timeline-color); + + color-scheme: dark; + + .light { + display: none; + } + + /* Categories */ + .categories.card, + .list-group-item { + background-color: var(--card-bg); + } + + .categories { + .card-header { + background-color: var(--card-header-bg); + } + + .list-group-item { + border-left: none; + border-right: none; + padding-left: 2rem; + border-color: var(--categories-border); + + &:last-child { + border-bottom-color: var(--card-bg); + } + } + } + + #archives li:nth-child(odd) { + background-image: linear-gradient( + to left, + rgb(26, 26, 30), + rgb(39, 39, 45), + rgb(39, 39, 45), + rgb(39, 39, 45), + rgb(26, 26, 30) + ); + } + + /* stylelint-disable-next-line selector-id-pattern */ + #disqus_thread { + color-scheme: none; + } +} /* dark-scheme */ diff --git a/_sass/colors/typography-light.scss b/_sass/colors/typography-light.scss new file mode 100644 index 0000000..7800074 --- /dev/null +++ b/_sass/colors/typography-light.scss @@ -0,0 +1,112 @@ +/* + * The syntax light mode typography colors + */ + +@mixin light-scheme { + /* Framework color */ + --main-bg: white; + --mask-bg: #c1c3c5; + --main-border-color: #f3f3f3; + + /* Common color */ + --text-color: #34343c; + --text-muted-color: #757575; + --text-muted-highlight-color: inherit; + --heading-color: #2a2a2a; + --label-color: #585858; + --blockquote-border-color: #eeeeee; + --blockquote-text-color: #757575; + --link-color: #0056b2; + --link-underline-color: #dee2e6; + --button-bg: #ffffff; + --btn-border-color: #e9ecef; + --btn-backtotop-color: #686868; + --btn-backtotop-border-color: #f1f1f1; + --btn-box-shadow: #eaeaea; + --checkbox-color: #c5c5c5; + --checkbox-checked-color: #07a8f7; + --img-bg: radial-gradient( + circle, + rgb(255, 255, 255) 0%, + rgb(239, 239, 239) 100% + ); + --shimmer-bg: linear-gradient( + 90deg, + rgba(250, 250, 250, 0) 0%, + rgba(232, 230, 230, 1) 50%, + rgba(250, 250, 250, 0) 100% + ); + + /* Sidebar */ + --site-title-color: rgb(113, 113, 113); + --site-subtitle-color: #717171; + --sidebar-bg: #f6f8fa; + --sidebar-border-color: #efefef; + --sidebar-muted-color: #545454; + --sidebar-active-color: #1d1d1d; + --sidebar-hover-bg: rgb(223, 233, 241, 0.64); + --sidebar-btn-bg: white; + --sidebar-btn-color: #8e8e8e; + --avatar-border-color: white; + + /* Topbar */ + --topbar-bg: rgb(255, 255, 255, 0.7); + --topbar-text-color: rgb(78, 78, 78); + --search-border-color: rgb(240, 240, 240); + --search-icon-color: #c2c6cc; + --input-focus-border-color: #b8b8b8; + + /* Home page */ + --post-list-text-color: dimgray; + --btn-patinator-text-color: #555555; + --btn-paginator-hover-color: var(--sidebar-bg); + + /* Posts */ + --toc-highlight: #0550ae; + --btn-share-color: gray; + --btn-share-hover-color: #0d6efd; + --card-bg: white; + --card-hovor-bg: #e2e2e2; + --card-shadow: rgb(104, 104, 104, 0.05) 0 2px 6px 0, + rgba(211, 209, 209, 0.15) 0 0 0 1px; + --footnote-target-bg: lightcyan; + --tb-odd-bg: #fbfcfd; + --tb-border-color: #eaeaea; + --dash-color: silver; + --kbd-wrap-color: #bdbdbd; + --kbd-text-color: var(--text-color); + --kbd-bg-color: white; + --prompt-text-color: rgb(46, 46, 46, 0.77); + --prompt-tip-bg: rgb(123, 247, 144, 0.2); + --prompt-tip-icon-color: #03b303; + --prompt-info-bg: #e1f5fe; + --prompt-info-icon-color: #0070cb; + --prompt-warning-bg: rgb(255, 243, 205); + --prompt-warning-icon-color: #ef9c03; + --prompt-danger-bg: rgb(248, 215, 218, 0.56); + --prompt-danger-icon-color: #df3c30; + + /* Tags */ + --tag-border: #dee2e6; + --tag-shadow: var(--btn-border-color); + --tag-hover: rgb(222, 226, 230); + --search-tag-bg: #f8f9fa; + + /* Categories */ + --categories-border: rgba(0, 0, 0, 0.125); + --categories-hover-bg: var(--btn-border-color); + --categories-icon-hover-color: darkslategray; + + /* Archive */ + --timeline-color: rgba(0, 0, 0, 0.075); + --timeline-node-bg: #c2c6cc; + --timeline-year-dot-color: #ffffff; + + [class^='prompt-'] { + --link-underline-color: rgb(219, 216, 216); + } + + .dark { + display: none; + } +} /* light-scheme */ diff --git a/_sass/dist/bootstrap.css b/_sass/dist/bootstrap.css new file mode 100644 index 0000000..7a21e03 --- /dev/null +++ b/_sass/dist/bootstrap.css @@ -0,0 +1,5 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-success:#198754;--bs-danger:#dc3545;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-bg:#e9ecef;--bs-tertiary-bg:#f8f9fa;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-lg:0.5rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15)}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}.small,small{font-size:.875em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button{text-transform:none}[role=button]{cursor:pointer}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}iframe{border:0}summary{display:list-item;cursor:pointer}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.list-unstyled{padding-left:0;list-style:none}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.figure{display:inline-block}.container{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}@media (min-width:1400px){.container{max-width:1320px}}:root{}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-1>*{flex:0 0 auto;width:100%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.g-0{--bs-gutter-x:0}.g-0{--bs-gutter-y:0}.g-4{--bs-gutter-x:1.5rem}.g-4{--bs-gutter-y:1.5rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}}@media (min-width:992px){.col-lg{flex:1 0 0%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-tooltip,.was-validated :valid~.valid-tooltip{display:block}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-tooltip{display:block}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-text:last-child{margin-bottom:0}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.toast{--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast:not(.show){display:none}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.align-middle{vertical-align:middle!important}.d-block{display:block!important}.d-flex{display:flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-100{height:100%!important}.flex-column{flex-direction:column!important}.flex-grow-1{flex-grow:1!important}.flex-wrap{flex-wrap:wrap!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-auto{margin-left:auto!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.pt-0{padding-top:0!important}.pt-2{padding-top:.5rem!important}.pe-4{padding-right:1.5rem!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.ps-0{padding-left:0!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-nowrap{white-space:nowrap!important}.text-muted{color:var(--bs-secondary-color)!important}.pe-none{pointer-events:none!important}.rounded-circle{border-radius:50%!important}@media (min-width:576px){.flex-sm-row{flex-direction:row!important}.me-sm-4{margin-right:1.5rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}}@media (min-width:768px){.flex-md-row-reverse{flex-direction:row-reverse!important}.mt-md-0{margin-top:0!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}}@media (min-width:992px){.flex-lg-row{flex-direction:row!important}.justify-content-lg-between{justify-content:space-between!important}.align-items-lg-center{align-items:center!important}.ms-lg-0{margin-left:0!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.ps-lg-2{padding-left:.5rem!important}}@media (min-width:1200px){.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}}@media (min-width:1400px){.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}} \ No newline at end of file diff --git a/_sass/layout/archives.scss b/_sass/layout/archives.scss new file mode 100644 index 0000000..3a2e86b --- /dev/null +++ b/_sass/layout/archives.scss @@ -0,0 +1,144 @@ +/* + Style for Archives +*/ + +#archives { + letter-spacing: 0.03rem; + + $timeline-width: 4px; + + %timeline { + content: ''; + width: $timeline-width; + position: relative; + float: left; + background-color: var(--timeline-color); + } + + .year { + height: 3.5rem; + font-size: 1.5rem; + position: relative; + left: 2px; + margin-left: -$timeline-width; + + &::before { + @extend %timeline; + + height: 72px; + left: 79px; + bottom: 16px; + } + + &:first-child::before { + @extend %timeline; + + height: 32px; + top: 24px; + } + + /* Year dot */ + &::after { + content: ''; + display: inline-block; + position: relative; + border-radius: 50%; + width: 12px; + height: 12px; + left: 21.5px; + border: 3px solid; + background-color: var(--timeline-year-dot-color); + border-color: var(--timeline-node-bg); + box-shadow: 0 0 2px 0 #c2c6cc; + z-index: 1; + } + } + + ul { + li { + font-size: 1.1rem; + line-height: 3rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:nth-child(odd) { + background-color: var(--main-bg, #ffffff); + background-image: linear-gradient( + to left, + #ffffff, + #fbfbfb, + #fbfbfb, + #fbfbfb, + #ffffff + ); + } + + &::before { + @extend %timeline; + + top: 0; + left: 77px; + height: 3.1rem; + } + } + + &:last-child li:last-child::before { + height: 1.5rem; + } + } /* #archives ul */ + + .date { + white-space: nowrap; + display: inline-block; + position: relative; + right: 0.5rem; + + &.month { + width: 1.4rem; + text-align: center; + } + + &.day { + font-size: 85%; + font-family: Lato, sans-serif; + } + } + + a { + /* post title in Archvies */ + margin-left: 2.5rem; + position: relative; + top: 0.1rem; + + &:hover { + border-bottom: none; + } + + &::before { + /* the dot before post title */ + content: ''; + display: inline-block; + position: relative; + border-radius: 50%; + width: 8px; + height: 8px; + float: left; + top: 1.35rem; + left: 71px; + background-color: var(--timeline-node-bg); + box-shadow: 0 0 3px 0 #c2c6cc; + z-index: 1; + } + } +} /* #archives */ + +@media all and (max-width: 576px) { + #archives { + margin-top: -1rem; + + ul { + letter-spacing: 0; + } + } +} diff --git a/_sass/layout/categories.scss b/_sass/layout/categories.scss new file mode 100644 index 0000000..f12b963 --- /dev/null +++ b/_sass/layout/categories.scss @@ -0,0 +1,83 @@ +/* + Style for Tab Categories +*/ + +%category-icon-color { + color: gray; +} + +.categories { + margin-bottom: 2rem; + border-color: var(--categories-border); + + &.card, + .list-group { + @extend %rounded; + } + + .card-header { + $radius: calc($radius-lg - 1px); + + padding: 0.75rem; + border-radius: $radius; + border-bottom: 0; + + &.hide-border-bottom { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + + i { + @extend %category-icon-color; + + font-size: 86%; /* fontawesome icons */ + } + + .list-group-item { + border-left: none; + border-right: none; + padding-left: 2rem; + + &:first-child { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &:last-child { + border-bottom: 0; + } + } +} /* .categories */ + +.category-trigger { + width: 1.7rem; + height: 1.7rem; + border-radius: 50%; + text-align: center; + color: #6c757d !important; + + i { + position: relative; + height: 0.7rem; + width: 1rem; + transition: transform 300ms ease; + } + + &:hover { + i { + color: var(--categories-icon-hover-color); + } + } +} + +/* only works on desktop */ +@media (hover: hover) { + .category-trigger:hover { + background-color: var(--categories-hover-bg); + } +} + +.rotate { + transform: rotate(-90deg); +} diff --git a/_sass/layout/category-tag.scss b/_sass/layout/category-tag.scss new file mode 100644 index 0000000..9e43a91 --- /dev/null +++ b/_sass/layout/category-tag.scss @@ -0,0 +1,72 @@ +/* + Style for page Category and Tag +*/ + +.dash { + margin: 0 0.5rem 0.6rem 0.5rem; + border-bottom: 2px dotted var(--dash-color); +} + +#page-category, +#page-tag { + ul > li { + line-height: 1.5rem; + padding: 0.6rem 0; + + /* dot */ + &::before { + background: #999999; + width: 5px; + height: 5px; + border-radius: 50%; + display: block; + content: ''; + position: relative; + top: 0.6rem; + margin-right: 0.5rem; + } + + /* post's title */ + > a { + @extend %no-bottom-border; + + font-size: 1.1rem; + } + } +} + +/* tag icon */ +#page-tag h1 > i { + font-size: 1.2rem; +} + +#page-category h1 > i { + font-size: 1.25rem; +} + +#page-category, +#page-tag, +#access-lastmod { + a:hover { + @extend %link-hover; + + margin-bottom: -1px; /* Avoid jumping */ + } +} + +@media all and (max-width: 576px) { + #page-category, + #page-tag { + ul > li { + &::before { + margin: 0 0.5rem; + } + + > a { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} diff --git a/_sass/layout/home.scss b/_sass/layout/home.scss new file mode 100644 index 0000000..0d95d7b --- /dev/null +++ b/_sass/layout/home.scss @@ -0,0 +1,189 @@ +/* + Style for Homepage +*/ + +#post-list { + margin-top: 2rem; + + .card-wrapper { + &:hover { + text-decoration: none; + } + + &:not(:last-child) { + margin-bottom: 1.25rem; + } + } + + .card { + border: 0; + background: none; + + %img-radius { + border-radius: $radius-lg $radius-lg 0 0; + } + + .preview-img { + @extend %img-radius; + + img { + @extend %img-radius; + } + } + + .card-body { + height: 100%; + padding: 1rem; + + .card-title { + @extend %text-clip; + + color: var(--heading-color) !important; + font-size: 1.25rem; + } + + %muted { + color: var(--text-muted-color) !important; + } + + .card-text.content { + @extend %muted; + + p { + @extend %text-clip; + + line-height: 1.5; + margin: 0; + } + } + + .post-meta { + @extend %muted; + + i { + &:not(:first-child) { + margin-left: 1.5rem; + } + } + + em { + @extend %normal-font-style; + + color: inherit; + } + + > div:first-child { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + } +} /* #post-list */ + +.pagination { + color: var(--text-color); + font-family: Lato, sans-serif; + justify-content: space-evenly; + + a:hover { + text-decoration: none; + } + + .page-item { + .page-link { + color: var(--btn-patinator-text-color); + padding: 0 0.6rem; + display: -webkit-box; + -webkit-box-pack: center; + -webkit-box-align: center; + border-radius: 0.5rem; + border: 0; + background-color: inherit; + } + + &.active { + .page-link { + background-color: var(--btn-paginator-hover-color); + } + } + + &:not(.active) { + .page-link { + &:hover { + box-shadow: inset var(--btn-border-color) 0 0 0 1px; + } + } + } + + &.disabled { + cursor: not-allowed; + + .page-link { + color: rgba(108, 117, 125, 0.57); + } + } + } /* .page-item */ +} /* .pagination */ + +/* Tablet */ +@media all and (min-width: 768px) { + %img-radius { + border-radius: 0 $radius-lg $radius-lg 0; + } + + #post-list { + .card { + .card-body { + padding: 1.75rem 1.75rem 1.25rem 1.75rem; + + .card-text { + display: inherit !important; + } + + .post-meta { + i { + &:not(:first-child) { + margin-left: 1.75rem; + } + } + } + } + } + } +} + +/* Hide SideBar and TOC */ +@media all and (max-width: 830px) { + .pagination { + .page-item { + &:not(:first-child):not(:last-child) { + display: none; + } + } + } +} + +/* Sidebar is visible */ +@media all and (min-width: 831px) { + #post-list { + margin-top: 2.5rem; + } + + .pagination { + font-size: 0.85rem; + justify-content: center; + + .page-item { + &:not(:last-child) { + margin-right: 0.7rem; + } + } + + .page-index { + display: none; + } + } /* .pagination */ +} diff --git a/_sass/layout/post.scss b/_sass/layout/post.scss new file mode 100644 index 0000000..815db93 --- /dev/null +++ b/_sass/layout/post.scss @@ -0,0 +1,370 @@ +/* + Post-specific style +*/ + +%btn-post-nav { + width: 50%; + position: relative; + border-color: var(--btn-border-color); +} + +@mixin dot($pl: 0.25rem, $pr: 0.25rem) { + content: '\2022'; + padding-left: $pl; + padding-right: $pr; +} + +header { + .post-desc { + @extend %heading; + + font-size: 1.125rem; + line-height: 1.6; + } + + .post-meta { + span + span::before { + @include dot; + } + + em, + time { + @extend %text-highlight; + } + + em { + a { + color: inherit; + } + } + } + + h1 + .post-meta { + margin-top: 1.5rem; + } +} + +.post-tail-wrapper { + @extend %text-sm; + + margin-top: 6rem; + border-bottom: 1px double var(--main-border-color); + + .license-wrapper { + line-height: 1.2rem; + + > a { + @extend %text-highlight; + + &:hover { + @extend %link-hover; + } + } + + span:last-child { + @extend %text-sm; + } + } /* .license-wrapper */ + + .post-meta a:not(:hover) { + @extend %link-underline; + } + + .share-wrapper { + vertical-align: middle; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + %icon-size { + font-size: 1.125rem; + } + + .share-icons { + display: flex; + + i { + color: var(--btn-share-color); + + @extend %icon-size; + } + + > * { + @extend %icon-size; + + margin-left: 0.5rem; + + &:hover { + i { + @extend %btn-share-hovor; + } + } + } + + button { + padding: 0; + border: none; + line-height: inherit; + + @extend %cursor-pointer; + } + } /* .share-icons */ + } /* .share-wrapper */ +} + +.share-mastodon { + /* See: https://github.com/justinribeiro/share-to-mastodon#properties */ + --wc-stm-font-family: $font-family-base; + --wc-stm-dialog-background-color: var(--card-bg); + --wc-stm-form-button-border: 1px solid var(--btn-border-color); + --wc-stm-form-submit-background-color: var(--sidebar-btn-bg); + --wc-stm-form-cancel-background-color: var(--sidebar-btn-bg); + --wc-stm-form-button-background-color-hover: #007bff; + --wc-stm-form-button-color-hover: white; + + font-size: 1rem; +} + +.post-tags { + line-height: 2rem; + + .post-tag { + &:hover { + @extend %link-hover; + @extend %tag-hover; + @extend %no-bottom-border; + } + } +} + +.post-navigation { + .btn { + @extend %btn-post-nav; + + &:not(:hover) { + color: var(--link-color); + } + + &:hover { + &:not(.disabled)::before { + color: whitesmoke; + } + } + + &.disabled { + @extend %btn-post-nav; + + pointer-events: auto; + cursor: not-allowed; + background: none; + color: gray; + } + + &.btn-outline-primary.disabled:focus { + box-shadow: none; + } + + &::before { + color: var(--text-muted-color); + font-size: 0.65rem; + text-transform: uppercase; + content: attr(aria-label); + } + + &:first-child { + border-radius: $radius-lg 0 0 $radius-lg; + left: 0.5px; + } + + &:last-child { + border-radius: 0 $radius-lg $radius-lg 0; + right: 0.5px; + } + } + + p { + font-size: 1.1rem; + line-height: 1.5rem; + margin-top: 0.3rem; + white-space: normal; + } +} /* .post-navigation */ + +@media (hover: hover) { + .post-navigation { + .btn, + .btn::before { + transition: all 0.35s ease-in-out; + } + } +} + +@-webkit-keyframes fade-up { + from { + opacity: 0; + position: relative; + top: 2rem; + } + + to { + opacity: 1; + position: relative; + top: 0; + } +} + +@keyframes fade-up { + from { + opacity: 0; + position: relative; + top: 2rem; + } + + to { + opacity: 1; + position: relative; + top: 0; + } +} + +#toc-wrapper { + border-left: 1px solid rgba(158, 158, 158, 0.17); + position: -webkit-sticky; + position: sticky; + top: 4rem; + transition: top 0.2s ease-in-out; + -webkit-animation: fade-up 0.8s; + animation: fade-up 0.8s; + + ul { + list-style: none; + font-size: 0.85rem; + line-height: 1.25; + padding-left: 0; + + li { + &:not(:last-child) { + margin: 0.4rem 0; + } + + a { + padding: 0.2rem 0 0.2rem 1.25rem; + } + } + + /* Overwrite TOC plugin style */ + + .toc-link { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + color: var(--toc-highlight); + text-decoration: none; + } + + &::before { + display: none; + } + } + + .is-active-link { + color: var(--toc-highlight) !important; + font-weight: 600; + + &::before { + display: inline-block; + width: 1px; + left: -1px; + height: 1.25rem; + background-color: var(--toc-highlight) !important; + } + } + + ul { + padding-left: 0.75rem; + } + } +} + +/* --- Related Posts --- */ + +#related-posts { + > h3 { + @include label(1.1rem, 600); + } + + time { + @extend %normal-font-style; + @extend %text-xs; + + color: var(--text-muted-color); + } + + p { + font-size: 0.9rem; + margin-bottom: 0.5rem; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .card { + h4 { + @extend %text-clip; + } + } +} + +/* stylelint-disable-next-line selector-id-pattern */ +#disqus_thread { + min-height: 8.5rem; +} + +.utterances { + max-width: 100%; +} + +%btn-share-hovor { + color: var(--btn-share-hover-color) !important; +} + +.share-label { + @include label(inherit, 400, inherit); + + &::after { + content: ':'; + } +} + +@media all and (max-width: 576px) { + .post-tail-bottom { + flex-wrap: wrap-reverse !important; + + > div:first-child { + width: 100%; + margin-top: 1rem; + } + } +} + +@media all and (max-width: 768px) { + .content > p > img { + max-width: calc(100% + 1rem); + } +} + +/* Hide SideBar and TOC */ +@media all and (max-width: 849px) { + .post-navigation { + padding-left: 0; + padding-right: 0; + margin-left: -0.5rem; + margin-right: -0.5rem; + } +} diff --git a/_sass/layout/tags.scss b/_sass/layout/tags.scss new file mode 100644 index 0000000..4cf5d3b --- /dev/null +++ b/_sass/layout/tags.scss @@ -0,0 +1,19 @@ +/* + Styles for Tab Tags +*/ + +.tag { + border-radius: 0.7em; + padding: 6px 8px 7px; + margin-right: 0.8rem; + line-height: 3rem; + letter-spacing: 0; + border: 1px solid var(--tag-border) !important; + box-shadow: 0 0 3px 0 var(--tag-shadow); + + span { + margin-left: 0.6em; + font-size: 0.7em; + font-family: Oswald, sans-serif; + } +} diff --git a/_sass/main.bundle.scss b/_sass/main.bundle.scss new file mode 100644 index 0000000..52e893f --- /dev/null +++ b/_sass/main.bundle.scss @@ -0,0 +1,2 @@ +@import 'dist/bootstrap'; +@import 'main'; diff --git a/_sass/main.scss b/_sass/main.scss new file mode 100644 index 0000000..1c2311d --- /dev/null +++ b/_sass/main.scss @@ -0,0 +1,13 @@ +@import 'colors/typography-light'; +@import 'colors/typography-dark'; +@import 'addon/variables'; +@import 'variables-hook'; +@import 'addon/module'; +@import 'addon/syntax'; +@import 'addon/commons'; +@import 'layout/home'; +@import 'layout/post'; +@import 'layout/tags'; +@import 'layout/archives'; +@import 'layout/categories'; +@import 'layout/category-tag'; diff --git a/_sass/variables-hook.scss b/_sass/variables-hook.scss new file mode 100644 index 0000000..f27e0eb --- /dev/null +++ b/_sass/variables-hook.scss @@ -0,0 +1,3 @@ +/* + Appending custom SCSS variables will override the default ones in `_sass/addon/variables.scsss` +*/ diff --git a/_tabs/about.md b/_tabs/about.md new file mode 100644 index 0000000..cfa4c91 --- /dev/null +++ b/_tabs/about.md @@ -0,0 +1,25 @@ +--- +# the default layout is 'page' +icon: fas fa-info-circle +order: 4 +--- + + +### Hi there 👋 + +⚡ My name is Josh Johanning and I am a Senior DevOps Architect with GitHub on the FastTrack team 🚀 + +⚡ Previously I was a Senior Cloud Automation Engineer with the Cognizant Microsoft Business Group (formerly 10th Magnitude) + +⚡ I store my miscellaneous GitHub scripts in [github-misc-scripts](https://github.com/joshjohanning/github-misc-scripts) repo + +⚡ I use my [ghas-demo](https://github.com/joshjohanning/ghas-demo) repository for my GitHub Advanced Security Demo - see PDF [here](https://github.com/joshjohanning/ghas-demo/blob/main/ghas-demo.pdf) + +⚡ I have been an avid user of Azure Pipelines and have my pipeline templates consolidated in my [pipeline-templates](https://github.com/joshjohanning/pipeline-templates) repo + +⚡ I blog about my DevOps experiences at [josh-ops.com](https://josh-ops.com) + +⚡ To contact me: + - 🌱 Leave a comment on one of my [posts](https://josh-ops.com)! + - 🌱 [Message me on LinkedIn](https://www.linkedin.com/in/joshua-johanning/) + - 🌱 [Tweet me](https://twitter.com/jjjettrain) diff --git a/_tabs/archives.md b/_tabs/archives.md new file mode 100644 index 0000000..c3abc59 --- /dev/null +++ b/_tabs/archives.md @@ -0,0 +1,5 @@ +--- +layout: archives +icon: fas fa-archive +order: 3 +--- diff --git a/_tabs/categories.md b/_tabs/categories.md new file mode 100644 index 0000000..2d241be --- /dev/null +++ b/_tabs/categories.md @@ -0,0 +1,5 @@ +--- +layout: categories +icon: fas fa-stream +order: 1 +--- diff --git a/_tabs/speaking.md b/_tabs/speaking.md new file mode 100644 index 0000000..3954dd0 --- /dev/null +++ b/_tabs/speaking.md @@ -0,0 +1,23 @@ +--- +title: Speaking +icon: fas fa-comment +order: 5 +--- + +Interested in having me speak? Contact me through [LinkedIn](https://www.linkedin.com/in/joshua-johanning/)! + +![VS Live 2022 Redmond - How to Implement Developer-optimized Application Security](/assets/img/sample/speaking.jpg){: .shadow } +_VS Live 2022 Redmond - How to Implement Developer-optimized Application Security_ + +## Speaking Engagements + +| Event | Location | Dates | Session(s) | +|-----------|-------------------|-------------------|------------| +| VS Live | Chicago, IL | Apr 29-
May 3, 2024 | - [Organizational Best Practices for Adopting GitHub Actions](https://vslive.com/Events/Chicago-2024/Sessions/Wednesday/W19-Organizational-Best-Practices-for-Adopting-GitHub-Actions.aspx)
- [Pimping your GitHub Codespace](https://vslive.com/Events/Chicago-2024/Sessions/Wednesday/W05-Pimping-your-GitHub-Codespace.aspx) | +| Techorama | Utrecht,
Netherlands | Oct 9-11, 2023 | - [A Day in the Life of a Developer Using GitHub](https://techorama.nl/agenda/session/a-day-in-the-life-of-a-developer-using-github/)
- [Implementing Developer-focused Application Security](https://techorama.nl/agenda/session/implementing-developerfocused-application-security/)
- [Organizational best practices for adopting GitHub Actions](https://techorama.nl/agenda/session/organizational-best-practices-for-adopting-github-actions/) +| VS Live | San Diego, CA | Aug 7-11, 2023 | - [Fast Focus: Pimping Your GitHub Codespace](https://vslive.com/Events/San-Diego-2023/Sessions/Wednesday/W13-Fast-Focus-Pimping-Your-GitHub-Codespace.aspx)
- [How to Implement Developer-optimized Application Security](https://vslive.com/Events/San-Diego-2023/Sessions/Thursday/TH14-How-to-Implement-Optimized-App-Security.aspx)
- [Creating Reusable CI/CD with Azure DevOps Pipeline Templating](https://vslive.com/Events/San-Diego-2023/Sessions/Wednesday/W22-Creating-Reusable-CICD-Templating.aspx) | +| GitHub
Universe | San Francisco, CA | Nov 9-10, 2022 | - [Workshop: Build a web app with Codespaces and deploy it to Microsoft Azure with GitHub](https://web.archive.org/web/20221018212229/https://githubuniverse.com/events/detail/on-site-schedule)
- [#AskGitHub Booth](https://github.blog/2022-10-11-the-github-universe-2022-agenda-is-live/#register-now) | +| VS Live | San Diego, CA | Sept 25-29, 2022 | - [Hands-on Lab: Build a Secure, Cloud Application in a Day with GitHub (co-facilitator)](https://vslive.com/Events/San-Diego-2022/Sessions/Sunday/S01-Handson-Lab-Build-a-Secure-Cloud-Application-in-a-Day-with-GitHub.aspx)
- [How to Implement Developer-optimized Application Security](https://vslive.com/Events/San-Diego-2022/Sessions/Wednesday/W08-How-to-Implement-Developer-optimized-Application-Security.aspx) | +| VS Live | Redmond, WA | Aug 8-12, 2022 | - [Hands-on Lab: Build a Secure, Cloud Application in a Day with GitHub (co-facilitator)](https://vslive.com/Events/Redmond-2022/Sessions/Monday/VM01-HOL-Build-a-Secure-Cloud-Application-in-a-Day-with-GitHub.aspx)
- [How to Implement Developer-optimized Application Security](https://vslive.com/Events/Redmond-2022/Sessions/Tuesday/VT12-How-to-Implement-Developeroptimized-Application-Security.aspx) | +| VS Live | Austin, TX | Jun 13-17, 2022 | - [Hands-on Lab: Build a Secure, Cloud Application in a Day with GitHub (co-facilitator)](https://vslive.com/Events/Austin-2022/Sessions/Monday/HOL03-Hands-on-Lab-Build-a-Secure-Cloud-Application-in-a-Day-with-GitHub.aspx)
- [How to Implement Developer-optimized Application Security (co-presenter)](https://vslive.com/Events/Austin-2022/Sessions/Wednesday/W04-How-to-Implement-Developer-optimized-Application-Security.aspx)
- [Keynote: GitHub – Have Another Look (co-presenter)](https://vslive.com/Events/Austin-2022/Sessions/Tuesday/Keynote.aspx) | +| | | | | diff --git a/_tabs/tags.md b/_tabs/tags.md new file mode 100644 index 0000000..ded3adc --- /dev/null +++ b/_tabs/tags.md @@ -0,0 +1,5 @@ +--- +layout: tags +icon: fas fa-tags +order: 2 +--- diff --git a/ads.txt b/ads.txt new file mode 100644 index 0000000..722fae6 --- /dev/null +++ b/ads.txt @@ -0,0 +1 @@ +google.com, pub-5596189433760482, DIRECT, f08c47fec0942fa0 diff --git a/assets/404.html b/assets/404.html new file mode 100644 index 0000000..af89d6d --- /dev/null +++ b/assets/404.html @@ -0,0 +1,14 @@ +--- +layout: page +title: "404: Page not found" +permalink: /404.html + +redirect_from: + - /norobots/ + - /assets/ + - /posts/ +--- + +{% include lang.html %} + +

{{ site.data.locales[lang].not_found.statement }}

diff --git a/assets/css/jekyll-theme-chirpy.scss b/assets/css/jekyll-theme-chirpy.scss new file mode 100644 index 0000000..d20545b --- /dev/null +++ b/assets/css/jekyll-theme-chirpy.scss @@ -0,0 +1,10 @@ +--- +--- + +@import 'main +{%- if jekyll.environment == 'production' -%} + .bundle +{%- endif -%} +'; + +/* append your custom style below */ diff --git a/assets/feed.xml b/assets/feed.xml new file mode 100644 index 0000000..0ab20e3 --- /dev/null +++ b/assets/feed.xml @@ -0,0 +1,54 @@ +--- +layout: compress +permalink: /feed.xml +# Atom Feed, reference: https://validator.w3.org/feed/docs/atom.html +--- + +{% capture source %} + + {{ "/" | absolute_url }} + {{ site.title }} + {{ site.description }} + {{ site.time | date_to_xmlschema }} + + {{ site.social.name }} + {{ "/" | absolute_url }} + + + + Jekyll + © {{ 'now' | date: '%Y' }} {{ site.social.name }} + {{ site.baseurl }}/assets/img/favicons/favicon.ico + {{ site.baseurl }}/assets/img/favicons/favicon-96x96.png + +{% for post in site.posts limit: 5 %} + {% assign post_absolute_url = post.url | absolute_url %} + + {{ post.title }} + + {{ post.date | date_to_xmlschema }} + {% if post.last_modified_at %} + {{ post.last_modified_at | date_to_xmlschema }} + {% else %} + {{ post.date | date_to_xmlschema }} + {% endif %} + {{ post_absolute_url }} + + + {{ post.author | default: site.social.name }} + + + {% if post.categories %} + {% for category in post.categories %} + + {% endfor %} + {% endif %} + + {% include post-description.html max_length=400 %} + + +{% endfor %} + +{% endcapture %} +{{ source | replace: '&', '&' }} diff --git a/assets/img/favicons/android-chrome-192x192.png b/assets/img/favicons/android-chrome-192x192.png new file mode 100644 index 0000000..7b55b47 Binary files /dev/null and b/assets/img/favicons/android-chrome-192x192.png differ diff --git a/assets/img/favicons/android-icon-144x144.png b/assets/img/favicons/android-icon-144x144.png new file mode 100644 index 0000000..6818a6a Binary files /dev/null and b/assets/img/favicons/android-icon-144x144.png differ diff --git a/assets/img/favicons/android-icon-192x192.png b/assets/img/favicons/android-icon-192x192.png new file mode 100644 index 0000000..7b55b47 Binary files /dev/null and b/assets/img/favicons/android-icon-192x192.png differ diff --git a/assets/img/favicons/android-icon-36x36.png b/assets/img/favicons/android-icon-36x36.png new file mode 100644 index 0000000..3cab6b9 Binary files /dev/null and b/assets/img/favicons/android-icon-36x36.png differ diff --git a/assets/img/favicons/android-icon-48x48.png b/assets/img/favicons/android-icon-48x48.png new file mode 100644 index 0000000..b43587a Binary files /dev/null and b/assets/img/favicons/android-icon-48x48.png differ diff --git a/assets/img/favicons/android-icon-72x72.png b/assets/img/favicons/android-icon-72x72.png new file mode 100644 index 0000000..e9724a5 Binary files /dev/null and b/assets/img/favicons/android-icon-72x72.png differ diff --git a/assets/img/favicons/android-icon-96x96.png b/assets/img/favicons/android-icon-96x96.png new file mode 100644 index 0000000..3981e21 Binary files /dev/null and b/assets/img/favicons/android-icon-96x96.png differ diff --git a/assets/img/favicons/apple-icon-114x114.png b/assets/img/favicons/apple-icon-114x114.png new file mode 100644 index 0000000..dd30aac Binary files /dev/null and b/assets/img/favicons/apple-icon-114x114.png differ diff --git a/assets/img/favicons/apple-icon-120x120.png b/assets/img/favicons/apple-icon-120x120.png new file mode 100644 index 0000000..be82544 Binary files /dev/null and b/assets/img/favicons/apple-icon-120x120.png differ diff --git a/assets/img/favicons/apple-icon-144x144.png b/assets/img/favicons/apple-icon-144x144.png new file mode 100644 index 0000000..6481ef4 Binary files /dev/null and b/assets/img/favicons/apple-icon-144x144.png differ diff --git a/assets/img/favicons/apple-icon-152x152.png b/assets/img/favicons/apple-icon-152x152.png new file mode 100644 index 0000000..7d1f80f Binary files /dev/null and b/assets/img/favicons/apple-icon-152x152.png differ diff --git a/assets/img/favicons/apple-icon-180x180.png b/assets/img/favicons/apple-icon-180x180.png new file mode 100644 index 0000000..39c96c7 Binary files /dev/null and b/assets/img/favicons/apple-icon-180x180.png differ diff --git a/assets/img/favicons/apple-icon-57x57.png b/assets/img/favicons/apple-icon-57x57.png new file mode 100644 index 0000000..d6af61e Binary files /dev/null and b/assets/img/favicons/apple-icon-57x57.png differ diff --git a/assets/img/favicons/apple-icon-60x60.png b/assets/img/favicons/apple-icon-60x60.png new file mode 100644 index 0000000..52c9991 Binary files /dev/null and b/assets/img/favicons/apple-icon-60x60.png differ diff --git a/assets/img/favicons/apple-icon-72x72.png b/assets/img/favicons/apple-icon-72x72.png new file mode 100644 index 0000000..4089949 Binary files /dev/null and b/assets/img/favicons/apple-icon-72x72.png differ diff --git a/assets/img/favicons/apple-icon-76x76.png b/assets/img/favicons/apple-icon-76x76.png new file mode 100644 index 0000000..2d509cf Binary files /dev/null and b/assets/img/favicons/apple-icon-76x76.png differ diff --git a/assets/img/favicons/apple-icon-precomposed.png b/assets/img/favicons/apple-icon-precomposed.png new file mode 100644 index 0000000..98582e5 Binary files /dev/null and b/assets/img/favicons/apple-icon-precomposed.png differ diff --git a/assets/img/favicons/apple-icon.png b/assets/img/favicons/apple-icon.png new file mode 100644 index 0000000..98582e5 Binary files /dev/null and b/assets/img/favicons/apple-icon.png differ diff --git a/assets/img/favicons/apple-touch-icon.png b/assets/img/favicons/apple-touch-icon.png new file mode 100644 index 0000000..98582e5 Binary files /dev/null and b/assets/img/favicons/apple-touch-icon.png differ diff --git a/assets/img/favicons/browserconfig.xml b/assets/img/favicons/browserconfig.xml new file mode 100644 index 0000000..a02a5c7 --- /dev/null +++ b/assets/img/favicons/browserconfig.xml @@ -0,0 +1,13 @@ +--- +layout: compress +--- + + + + + + + #da532c + + + diff --git a/assets/img/favicons/favicon-16x16.png b/assets/img/favicons/favicon-16x16.png new file mode 100644 index 0000000..2885a62 Binary files /dev/null and b/assets/img/favicons/favicon-16x16.png differ diff --git a/assets/img/favicons/favicon-16x16.xcf b/assets/img/favicons/favicon-16x16.xcf new file mode 100644 index 0000000..9206116 Binary files /dev/null and b/assets/img/favicons/favicon-16x16.xcf differ diff --git a/assets/img/favicons/favicon-32x32.png b/assets/img/favicons/favicon-32x32.png new file mode 100644 index 0000000..445883a Binary files /dev/null and b/assets/img/favicons/favicon-32x32.png differ diff --git a/assets/img/favicons/favicon-96x96.png b/assets/img/favicons/favicon-96x96.png new file mode 100644 index 0000000..679667f Binary files /dev/null and b/assets/img/favicons/favicon-96x96.png differ diff --git a/assets/img/favicons/favicon.ico b/assets/img/favicons/favicon.ico new file mode 100644 index 0000000..26365a4 Binary files /dev/null and b/assets/img/favicons/favicon.ico differ diff --git a/assets/img/favicons/favicon.xcf b/assets/img/favicons/favicon.xcf new file mode 100644 index 0000000..26f13c9 Binary files /dev/null and b/assets/img/favicons/favicon.xcf differ diff --git a/assets/img/favicons/ms-icon-144x144.png b/assets/img/favicons/ms-icon-144x144.png new file mode 100644 index 0000000..6481ef4 Binary files /dev/null and b/assets/img/favicons/ms-icon-144x144.png differ diff --git a/assets/img/favicons/ms-icon-150x150.png b/assets/img/favicons/ms-icon-150x150.png new file mode 100644 index 0000000..3dea295 Binary files /dev/null and b/assets/img/favicons/ms-icon-150x150.png differ diff --git a/assets/img/favicons/ms-icon-310x310.png b/assets/img/favicons/ms-icon-310x310.png new file mode 100644 index 0000000..6aa7622 Binary files /dev/null and b/assets/img/favicons/ms-icon-310x310.png differ diff --git a/assets/img/favicons/ms-icon-70x70.png b/assets/img/favicons/ms-icon-70x70.png new file mode 100644 index 0000000..b2ebc78 Binary files /dev/null and b/assets/img/favicons/ms-icon-70x70.png differ diff --git a/assets/img/favicons/site.webmanifest b/assets/img/favicons/site.webmanifest new file mode 100644 index 0000000..03c6113 --- /dev/null +++ b/assets/img/favicons/site.webmanifest @@ -0,0 +1,26 @@ +--- +layout: compress +--- + +{% assign favicon_path = "/assets/img/favicons" | relative_url %} + +{ + "name": "{{ site.title }}", + "short_name": "{{ site.title }}", + "description": "{{ site.description }}", + "icons": [ + { + "src": "{{ favicon_path }}/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "{{ favicon_path }}/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + }], + "start_url": "{{ '/index.html' | relative_url }}", + "theme_color": "#2a1e6b", + "background_color": "#ffffff", + "display": "fullscreen" +} diff --git a/assets/img/sample/headshot.png b/assets/img/sample/headshot.png new file mode 100644 index 0000000..6815258 Binary files /dev/null and b/assets/img/sample/headshot.png differ diff --git a/assets/img/sample/logo.png b/assets/img/sample/logo.png new file mode 100644 index 0000000..cfbe091 Binary files /dev/null and b/assets/img/sample/logo.png differ diff --git a/assets/img/sample/logo.xcf b/assets/img/sample/logo.xcf new file mode 100644 index 0000000..2125c74 Binary files /dev/null and b/assets/img/sample/logo.xcf differ diff --git a/assets/img/sample/speaking.jpg b/assets/img/sample/speaking.jpg new file mode 100644 index 0000000..a1b9e33 Binary files /dev/null and b/assets/img/sample/speaking.jpg differ diff --git a/assets/js/data/mathjax.js b/assets/js/data/mathjax.js new file mode 100644 index 0000000..ca3d0de --- /dev/null +++ b/assets/js/data/mathjax.js @@ -0,0 +1,25 @@ +--- +layout: compress +# WARNING: Don't use '//' to comment out code, use '{% comment %}' and '{% endcomment %}' instead. +--- + +{%- comment -%} + See: +{%- endcomment -%} + +MathJax = { + tex: { + {%- comment -%} start/end delimiter pairs for in-line math {%- endcomment -%} + inlineMath: [ + ['$', '$'], + ['\\(', '\\)'] + ], + {%- comment -%} start/end delimiter pairs for display math {%- endcomment -%} + displayMath: [ + ['$$', '$$'], + ['\\[', '\\]'] + ], + {%- comment -%} equation numbering {%- endcomment -%} + tags: 'ams' + } +}; diff --git a/assets/js/data/search.json b/assets/js/data/search.json new file mode 100644 index 0000000..2601ed0 --- /dev/null +++ b/assets/js/data/search.json @@ -0,0 +1,20 @@ +--- +layout: compress +swcache: true +--- + +[ + {% for post in site.posts %} + { + "title": {{ post.title | jsonify }}, + "url": {{ post.url | relative_url | jsonify }}, + "categories": {{ post.categories | join: ', ' | jsonify }}, + "tags": {{ post.tags | join: ', ' | jsonify }}, + "date": "{{ post.date }}", + {% include no-linenos.html content=post.content %} + {% assign _content = content | strip_html | strip_newlines %} + "snippet": {{ _content | truncate: 200 | jsonify }}, + "content": {{ _content | jsonify }} + }{% unless forloop.last %},{% endunless %} + {% endfor %} +] diff --git a/assets/js/data/swconf.js b/assets/js/data/swconf.js new file mode 100644 index 0000000..798888a --- /dev/null +++ b/assets/js/data/swconf.js @@ -0,0 +1,47 @@ +--- +layout: compress +permalink: '/:path/swconf.js' +# Note that this file will be fetched by the ServiceWorker, so it will not be cached. +--- + +const swconf = { + {% if site.pwa.cache.enabled %} + cacheName: 'chirpy-{{ "now" | date: "%s" }}', + + {%- comment -%} Resources added to the cache during PWA installation. {%- endcomment -%} + resources: [ + '{{ "/assets/css/:THEME.css" | replace: ':THEME', site.theme | relative_url }}', + '{{ "/" | relative_url }}', + {% for tab in site.tabs %} + '{{- tab.url | relative_url -}}', + {% endfor %} + + {% assign cache_list = site.static_files | where: 'swcache', true %} + {% for file in cache_list %} + '{{ file.path | relative_url }}'{%- unless forloop.last -%},{%- endunless -%} + {% endfor %} + ], + + interceptor: { + {%- comment -%} URLs containing the following paths will not be cached. {%- endcomment -%} + paths: [ + {% for path in site.pwa.cache.deny_paths %} + {% unless path == empty %} + '{{ path | relative_url }}'{%- unless forloop.last -%},{%- endunless -%} + {% endunless %} + {% endfor %} + ], + + {%- comment -%} URLs containing the following prefixes will not be cached. {%- endcomment -%} + urlPrefixes: [ + {% if site.analytics.goatcounter.id != nil and site.pageviews.provider == 'goatcounter' %} + 'https://{{ site.analytics.goatcounter.id }}.goatcounter.com/counter/' + {% endif %} + ] + }, + + purge: false + {% else %} + purge: true + {% endif %} +}; diff --git a/assets/js/dist/app.min.js b/assets/js/dist/app.min.js new file mode 100644 index 0000000..f69fadd --- /dev/null +++ b/assets/js/dist/app.min.js @@ -0,0 +1,7 @@ +--- +permalink: /:basename +--- +/*! + * jekyll-theme-chirpy v7.1.1 | © 2019 Cotes Chung | MIT Licensed | https://github.com/cotes2020/jekyll-theme-chirpy/ + */ +!function(){"use strict";const e=new Map;var t={set(t,n,o){e.has(t)||e.set(t,new Map);const r=e.get(t);r.has(n)||0===r.size?r.set(n,o):console.error("Bootstrap doesn't allow more than one instance per element. Bound instance: ".concat(Array.from(r.keys())[0],"."))},get:(t,n)=>e.has(t)&&e.get(t).get(n)||null,remove(t,n){if(!e.has(t))return;const o=e.get(t);o.delete(n),0===o.size&&e.delete(t)}};const n="transitionend",o=e=>(e&&window.CSS&&window.CSS.escape&&(e=e.replace(/#([^\s"#']+)/g,((e,t)=>"#".concat(CSS.escape(t))))),e),r=e=>!(!e||"object"!=typeof e)&&(void 0!==e.jquery&&(e=e[0]),void 0!==e.nodeType),i=e=>r(e)?e.jquery?e[0]:e:"string"==typeof e&&e.length>0?document.querySelector(o(e)):null,s=e=>!e||e.nodeType!==Node.ELEMENT_NODE||(!!e.classList.contains("disabled")||(void 0!==e.disabled?e.disabled:e.hasAttribute("disabled")&&"false"!==e.getAttribute("disabled"))),c=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,a=[],l=function(e){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e;return"function"==typeof e?e(...arguments.length>1&&void 0!==arguments[1]?arguments[1]:[]):t},u=function(e,t){if(!(!(arguments.length>2&&void 0!==arguments[2])||arguments[2]))return void l(e);const o=(e=>{if(!e)return 0;let{transitionDuration:t,transitionDelay:n}=window.getComputedStyle(e);const o=Number.parseFloat(t),r=Number.parseFloat(n);return o||r?(t=t.split(",")[0],n=n.split(",")[0],1e3*(Number.parseFloat(t)+Number.parseFloat(n))):0})(t)+5;let r=!1;const i=o=>{let{target:s}=o;s===t&&(r=!0,t.removeEventListener(n,i),l(e))};t.addEventListener(n,i),setTimeout((()=>{r||t.dispatchEvent(new Event(n))}),o)},d=/[^.]*(?=\..*)\.|.*/,f=/\..*/,h=/::\d+$/,g={};let m=1;const p={mouseenter:"mouseover",mouseleave:"mouseout"},b=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function v(e,t){return t&&"".concat(t,"::").concat(m++)||e.uidEvent||m++}function _(e){const t=v(e);return e.uidEvent=t,g[t]=g[t]||{},g[t]}function y(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;return Object.values(e).find((e=>e.callable===t&&e.delegationSelector===n))}function E(e,t,n){const o="string"==typeof t,r=o?n:t||n;let i=C(e);return b.has(i)||(i=e),[o,r,i]}function w(e,t,n,o,r){if("string"!=typeof t||!e)return;let[i,s,c]=E(t,n,o);if(t in p){const e=e=>function(t){if(!t.relatedTarget||t.relatedTarget!==t.delegateTarget&&!t.delegateTarget.contains(t.relatedTarget))return e.call(this,t)};s=e(s)}const a=_(e),l=a[c]||(a[c]={}),u=y(l,s,i?n:null);if(u)return void(u.oneOff=u.oneOff&&r);const f=v(s,t.replace(d,"")),h=i?function(e,t,n){return function o(r){const i=e.querySelectorAll(t);for(let{target:s}=r;s&&s!==this;s=s.parentNode)for(const c of i)if(c===s)return T(r,{delegateTarget:s}),o.oneOff&&O.off(e,r.type,t,n),n.apply(s,[r])}}(e,n,s):function(e,t){return function n(o){return T(o,{delegateTarget:e}),n.oneOff&&O.off(e,o.type,t),t.apply(e,[o])}}(e,s);h.delegationSelector=i?n:null,h.callable=s,h.oneOff=r,h.uidEvent=f,l[f]=h,e.addEventListener(c,h,i)}function A(e,t,n,o,r){const i=y(t[n],o,r);i&&(e.removeEventListener(n,i,Boolean(r)),delete t[n][i.uidEvent])}function S(e,t,n,o){const r=t[n]||{};for(const[i,s]of Object.entries(r))i.includes(o)&&A(e,t,n,s.callable,s.delegationSelector)}function C(e){return e=e.replace(f,""),p[e]||e}const O={on(e,t,n,o){w(e,t,n,o,!1)},one(e,t,n,o){w(e,t,n,o,!0)},off(e,t,n,o){if("string"!=typeof t||!e)return;const[r,i,s]=E(t,n,o),c=s!==t,a=_(e),l=a[s]||{},u=t.startsWith(".");if(void 0===i){if(u)for(const n of Object.keys(a))S(e,a,n,t.slice(1));for(const[n,o]of Object.entries(l)){const r=n.replace(h,"");c&&!t.includes(r)||A(e,a,s,o.callable,o.delegationSelector)}}else{if(!Object.keys(l).length)return;A(e,a,s,i,r?n:null)}},trigger(e,t,n){if("string"!=typeof t||!e)return null;const o=c();let r=null,i=!0,s=!0,a=!1;t!==C(t)&&o&&(r=o.Event(t,n),o(e).trigger(r),i=!r.isPropagationStopped(),s=!r.isImmediatePropagationStopped(),a=r.isDefaultPrevented());const l=T(new Event(t,{bubbles:i,cancelable:!0}),n);return a&&l.preventDefault(),s&&e.dispatchEvent(l),l.defaultPrevented&&r&&r.preventDefault(),l}};function T(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(const[n,o]of Object.entries(t))try{e[n]=o}catch{Object.defineProperty(e,n,{configurable:!0,get:()=>o})}return e}function N(e){if("true"===e)return!0;if("false"===e)return!1;if(e===Number(e).toString())return Number(e);if(""===e||"null"===e)return null;if("string"!=typeof e)return e;try{return JSON.parse(decodeURIComponent(e))}catch{return e}}function j(e){return e.replace(/[A-Z]/g,(e=>"-".concat(e.toLowerCase())))}const L={setDataAttribute(e,t,n){e.setAttribute("data-bs-".concat(j(t)),n)},removeDataAttribute(e,t){e.removeAttribute("data-bs-".concat(j(t)))},getDataAttributes(e){if(!e)return{};const t={},n=Object.keys(e.dataset).filter((e=>e.startsWith("bs")&&!e.startsWith("bsConfig")));for(const o of n){let n=o.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),t[n]=N(e.dataset[o])}return t},getDataAttribute:(e,t)=>N(e.getAttribute("data-bs-".concat(j(t))))};class D{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(e){return e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e}_mergeConfigObj(e,t){const n=r(t)?L.getDataAttribute(t,"config"):{};return{...this.constructor.Default,..."object"==typeof n?n:{},...r(t)?L.getDataAttributes(t):{},..."object"==typeof e?e:{}}}_typeCheckConfig(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.constructor.DefaultType;for(const[o,i]of Object.entries(t)){const t=e[o],s=r(t)?"element":null==(n=t)?"".concat(n):Object.prototype.toString.call(n).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(i).test(s))throw new TypeError("".concat(this.constructor.NAME.toUpperCase(),': Option "').concat(o,'" provided type "').concat(s,'" but expected type "').concat(i,'".'))}var n}}class I extends D{constructor(e,n){super(),(e=i(e))&&(this._element=e,this._config=this._getConfig(n),t.set(this._element,this.constructor.DATA_KEY,this))}dispose(){t.remove(this._element,this.constructor.DATA_KEY),O.off(this._element,this.constructor.EVENT_KEY);for(const e of Object.getOwnPropertyNames(this))this[e]=null}_queueCallback(e,t){u(e,t,!(arguments.length>2&&void 0!==arguments[2])||arguments[2])}_getConfig(e){return e=this._mergeConfigObj(e,this._element),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}static getInstance(e){return t.get(i(e),this.DATA_KEY)}static getOrCreateInstance(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return this.getInstance(e)||new this(e,"object"==typeof t?t:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return"bs.".concat(this.NAME)}static get EVENT_KEY(){return".".concat(this.DATA_KEY)}static eventName(e){return"".concat(e).concat(this.EVENT_KEY)}}const k=e=>{let t=e.getAttribute("data-bs-target");if(!t||"#"===t){let n=e.getAttribute("href");if(!n||!n.includes("#")&&!n.startsWith("."))return null;n.includes("#")&&!n.startsWith("#")&&(n="#".concat(n.split("#")[1])),t=n&&"#"!==n?n.trim():null}return t?t.split(",").map((e=>o(e))).join(","):null},M={find(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return[].concat(...Element.prototype.querySelectorAll.call(t,e))},findOne(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return Element.prototype.querySelector.call(t,e)},children:(e,t)=>[].concat(...e.children).filter((e=>e.matches(t))),parents(e,t){const n=[];let o=e.parentNode.closest(t);for(;o;)n.push(o),o=o.parentNode.closest(t);return n},prev(e,t){let n=e.previousElementSibling;for(;n;){if(n.matches(t))return[n];n=n.previousElementSibling}return[]},next(e,t){let n=e.nextElementSibling;for(;n;){if(n.matches(t))return[n];n=n.nextElementSibling}return[]},focusableChildren(e){const t=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((e=>"".concat(e,':not([tabindex^="-"])'))).join(",");return this.find(t,e).filter((e=>!s(e)&&(e=>{if(!r(e)||0===e.getClientRects().length)return!1;const t="visible"===getComputedStyle(e).getPropertyValue("visibility"),n=e.closest("details:not([open])");if(!n)return t;if(n!==e){const t=e.closest("summary");if(t&&t.parentNode!==n)return!1;if(null===t)return!1}return t})(e)))},getSelectorFromElement(e){const t=k(e);return t&&M.findOne(t)?t:null},getElementFromSelector(e){const t=k(e);return t?M.findOne(t):null},getMultipleElementsFromSelector(e){const t=k(e);return t?M.find(t):[]}},K=".".concat("bs.toast"),P="mouseover".concat(K),q="mouseout".concat(K),x="focusin".concat(K),W="focusout".concat(K),Y="hide".concat(K),F="hidden".concat(K),R="show".concat(K),V="shown".concat(K),Q="hide",z="show",B="showing",H={animation:"boolean",autohide:"boolean",delay:"number"},U={animation:!0,autohide:!0,delay:5e3};class G extends I{constructor(e,t){super(e,t),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return U}static get DefaultType(){return H}static get NAME(){return"toast"}show(){if(O.trigger(this._element,R).defaultPrevented)return;this._clearTimeout(),this._config.animation&&this._element.classList.add("fade");this._element.classList.remove(Q),this._element.offsetHeight,this._element.classList.add(z,B),this._queueCallback((()=>{this._element.classList.remove(B),O.trigger(this._element,V),this._maybeScheduleHide()}),this._element,this._config.animation)}hide(){if(!this.isShown())return;if(O.trigger(this._element,Y).defaultPrevented)return;this._element.classList.add(B),this._queueCallback((()=>{this._element.classList.add(Q),this._element.classList.remove(B,z),O.trigger(this._element,F)}),this._element,this._config.animation)}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(z),super.dispose()}isShown(){return this._element.classList.contains(z)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(e,t){switch(e.type){case"mouseover":case"mouseout":this._hasMouseInteraction=t;break;case"focusin":case"focusout":this._hasKeyboardInteraction=t}if(t)return void this._clearTimeout();const n=e.relatedTarget;this._element===n||this._element.contains(n)||this._maybeScheduleHide()}_setListeners(){O.on(this._element,P,(e=>this._onInteraction(e,!0))),O.on(this._element,q,(e=>this._onInteraction(e,!1))),O.on(this._element,x,(e=>this._onInteraction(e,!0))),O.on(this._element,W,(e=>this._onInteraction(e,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(e){return this.each((function(){const t=G.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError('No method named "'.concat(e,'"'));t[e](this)}}))}}var J,Z;if(function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"hide";const n="click.dismiss".concat(e.EVENT_KEY),o=e.NAME;O.on(document,n,'[data-bs-dismiss="'.concat(o,'"]'),(function(n){if(["A","AREA"].includes(this.tagName)&&n.preventDefault(),s(this))return;const r=M.getElementFromSelector(this)||this.closest(".".concat(o));e.getOrCreateInstance(r)[t]()}))}(G),J=G,Z=()=>{const e=c();if(e){const t=J.NAME,n=e.fn[t];e.fn[t]=J.jQueryInterface,e.fn[t].Constructor=J,e.fn[t].noConflict=()=>(e.fn[t]=n,J.jQueryInterface)}},"loading"===document.readyState?(a.length||document.addEventListener("DOMContentLoaded",(()=>{for(const e of a)e()})),a.push(Z)):Z(),"serviceWorker"in navigator){const e=new URL(document.currentScript.src),t=e.searchParams.get("register"),n=e.searchParams.get("baseurl");if(t){const e="".concat(n,"/sw.min.js"),t=document.getElementById("notification"),o=t.querySelector(".toast-body>button"),r=G.getOrCreateInstance(t);navigator.serviceWorker.register(e).then((e=>{e.waiting&&r.show(),e.addEventListener("updatefound",(()=>{e.installing.addEventListener("statechange",(()=>{e.waiting&&navigator.serviceWorker.controller&&r.show()}))})),o.addEventListener("click",(()=>{e.waiting&&e.waiting.postMessage("SKIP_WAITING"),r.hide()}))}));let i=!1;navigator.serviceWorker.addEventListener("controllerchange",(()=>{i||(window.location.reload(),i=!0)}))}else navigator.serviceWorker.getRegistrations().then((function(e){for(let t of e)t.unregister()}))}}(); diff --git a/assets/js/dist/categories.min.js b/assets/js/dist/categories.min.js new file mode 100644 index 0000000..72ce146 --- /dev/null +++ b/assets/js/dist/categories.min.js @@ -0,0 +1,4 @@ +/*! + * jekyll-theme-chirpy v7.1.1 | © 2019 Cotes Chung | MIT Licensed | https://github.com/cotes2020/jekyll-theme-chirpy/ + */ +!function(){"use strict";var e="top",t="bottom",n="right",i="left",o="auto",r=[e,t,n,i],s="start",a="end",c="clippingParents",l="viewport",f="popper",u="reference",d=r.reduce((function(e,t){return e.concat([t+"-"+s,t+"-"+a])}),[]),p=[].concat(r,[o]).reduce((function(e,t){return e.concat([t,t+"-"+s,t+"-"+a])}),[]),h="beforeRead",m="read",g="afterRead",v="beforeMain",b="main",y="afterMain",_="beforeWrite",w="write",E="afterWrite",O=[h,m,g,v,b,y,_,w,E];function x(e){return e?(e.nodeName||"").toLowerCase():null}function A(e){if(null==e)return window;if("[object Window]"!==e.toString()){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function C(e){return e instanceof A(e).Element||e instanceof Element}function L(e){return e instanceof A(e).HTMLElement||e instanceof HTMLElement}function T(e){return"undefined"!=typeof ShadowRoot&&(e instanceof A(e).ShadowRoot||e instanceof ShadowRoot)}var j={name:"applyStyles",enabled:!0,phase:"write",fn:function(e){var t=e.state;Object.keys(t.elements).forEach((function(e){var n=t.styles[e]||{},i=t.attributes[e]||{},o=t.elements[e];L(o)&&x(o)&&(Object.assign(o.style,n),Object.keys(i).forEach((function(e){var t=i[e];!1===t?o.removeAttribute(e):o.setAttribute(e,!0===t?"":t)})))}))},effect:function(e){var t=e.state,n={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.styles=n,t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach((function(e){var i=t.elements[e],o=t.attributes[e]||{},r=Object.keys(t.styles.hasOwnProperty(e)?t.styles[e]:n[e]).reduce((function(e,t){return e[t]="",e}),{});L(i)&&x(i)&&(Object.assign(i.style,r),Object.keys(o).forEach((function(e){i.removeAttribute(e)})))}))}},requires:["computeStyles"]};function S(e){return e.split("-")[0]}var D=Math.max,k=Math.min,P=Math.round;function N(){var e=navigator.userAgentData;return null!=e&&e.brands&&Array.isArray(e.brands)?e.brands.map((function(e){return e.brand+"/"+e.version})).join(" "):navigator.userAgent}function M(){return!/^((?!chrome|android).)*safari/i.test(N())}function B(e,t,n){void 0===t&&(t=!1),void 0===n&&(n=!1);var i=e.getBoundingClientRect(),o=1,r=1;t&&L(e)&&(o=e.offsetWidth>0&&P(i.width)/e.offsetWidth||1,r=e.offsetHeight>0&&P(i.height)/e.offsetHeight||1);var s=(C(e)?A(e):window).visualViewport,a=!M()&&n,c=(i.left+(a&&s?s.offsetLeft:0))/o,l=(i.top+(a&&s?s.offsetTop:0))/r,f=i.width/o,u=i.height/r;return{width:f,height:u,top:l,right:c+f,bottom:l+u,left:c,x:c,y:l}}function F(e){var t=B(e),n=e.offsetWidth,i=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-i)<=1&&(i=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:i}}function H(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&T(n)){var i=t;do{if(i&&e.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function I(e){return A(e).getComputedStyle(e)}function W(e){return["table","td","th"].indexOf(x(e))>=0}function z(e){return((C(e)?e.ownerDocument:e.document)||window.document).documentElement}function q(e){return"html"===x(e)?e:e.assignedSlot||e.parentNode||(T(e)?e.host:null)||z(e)}function R(e){return L(e)&&"fixed"!==I(e).position?e.offsetParent:null}function V(e){for(var t=A(e),n=R(e);n&&W(n)&&"static"===I(n).position;)n=R(n);return n&&("html"===x(n)||"body"===x(n)&&"static"===I(n).position)?t:n||function(e){var t=/firefox/i.test(N());if(/Trident/i.test(N())&&L(e)&&"fixed"===I(e).position)return null;var n=q(e);for(T(n)&&(n=n.host);L(n)&&["html","body"].indexOf(x(n))<0;){var i=I(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||t&&"filter"===i.willChange||t&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(e)||t}function Y(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function U(e,t,n){return D(e,k(t,n))}function K(e){return Object.assign({},{top:0,right:0,bottom:0,left:0},e)}function Q(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}var $={name:"arrow",enabled:!0,phase:"main",fn:function(o){var s,a=o.state,c=o.name,l=o.options,f=a.elements.arrow,u=a.modifiersData.popperOffsets,d=S(a.placement),p=Y(d),h=[i,n].indexOf(d)>=0?"height":"width";if(f&&u){var m=function(e,t){return K("number"!=typeof(e="function"==typeof e?e(Object.assign({},t.rects,{placement:t.placement})):e)?e:Q(e,r))}(l.padding,a),g=F(f),v="y"===p?e:i,b="y"===p?t:n,y=a.rects.reference[h]+a.rects.reference[p]-u[p]-a.rects.popper[h],_=u[p]-a.rects.reference[p],w=V(f),E=w?"y"===p?w.clientHeight||0:w.clientWidth||0:0,O=y/2-_/2,x=m[v],A=E-g[h]-m[b],C=E/2-g[h]/2+O,L=U(x,C,A),T=p;a.modifiersData[c]=((s={})[T]=L,s.centerOffset=L-C,s)}},effect:function(e){var t=e.state,n=e.options.element,i=void 0===n?"[data-popper-arrow]":n;null!=i&&("string"!=typeof i||(i=t.elements.popper.querySelector(i)))&&H(t.elements.popper,i)&&(t.elements.arrow=i)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function G(e){return e.split("-")[1]}var X={top:"auto",right:"auto",bottom:"auto",left:"auto"};function J(o){var r,s=o.popper,c=o.popperRect,l=o.placement,f=o.variation,u=o.offsets,d=o.position,p=o.gpuAcceleration,h=o.adaptive,m=o.roundOffsets,g=o.isFixed,v=u.x,b=void 0===v?0:v,y=u.y,_=void 0===y?0:y,w="function"==typeof m?m({x:b,y:_}):{x:b,y:_};b=w.x,_=w.y;var E=u.hasOwnProperty("x"),O=u.hasOwnProperty("y"),x=i,C=e,L=window;if(h){var T=V(s),j="clientHeight",S="clientWidth";if(T===A(s)&&"static"!==I(T=z(s)).position&&"absolute"===d&&(j="scrollHeight",S="scrollWidth"),l===e||(l===i||l===n)&&f===a)C=t,_-=(g&&T===L&&L.visualViewport?L.visualViewport.height:T[j])-c.height,_*=p?1:-1;if(l===i||(l===e||l===t)&&f===a)x=n,b-=(g&&T===L&&L.visualViewport?L.visualViewport.width:T[S])-c.width,b*=p?1:-1}var D,k=Object.assign({position:d},h&&X),N=!0===m?function(e,t){var n=e.x,i=e.y,o=t.devicePixelRatio||1;return{x:P(n*o)/o||0,y:P(i*o)/o||0}}({x:b,y:_},A(s)):{x:b,y:_};return b=N.x,_=N.y,p?Object.assign({},k,((D={})[C]=O?"0":"",D[x]=E?"0":"",D.transform=(L.devicePixelRatio||1)<=1?"translate("+b+"px, "+_+"px)":"translate3d("+b+"px, "+_+"px, 0)",D)):Object.assign({},k,((r={})[C]=O?_+"px":"",r[x]=E?b+"px":"",r.transform="",r))}var Z={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(e){var t=e.state,n=e.options,i=n.gpuAcceleration,o=void 0===i||i,r=n.adaptive,s=void 0===r||r,a=n.roundOffsets,c=void 0===a||a,l={placement:S(t.placement),variation:G(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:o,isFixed:"fixed"===t.options.strategy};null!=t.modifiersData.popperOffsets&&(t.styles.popper=Object.assign({},t.styles.popper,J(Object.assign({},l,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:s,roundOffsets:c})))),null!=t.modifiersData.arrow&&(t.styles.arrow=Object.assign({},t.styles.arrow,J(Object.assign({},l,{offsets:t.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:c})))),t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-placement":t.placement})},data:{}},ee={passive:!0};var te={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(e){var t=e.state,n=e.instance,i=e.options,o=i.scroll,r=void 0===o||o,s=i.resize,a=void 0===s||s,c=A(t.elements.popper),l=[].concat(t.scrollParents.reference,t.scrollParents.popper);return r&&l.forEach((function(e){e.addEventListener("scroll",n.update,ee)})),a&&c.addEventListener("resize",n.update,ee),function(){r&&l.forEach((function(e){e.removeEventListener("scroll",n.update,ee)})),a&&c.removeEventListener("resize",n.update,ee)}},data:{}},ne={left:"right",right:"left",bottom:"top",top:"bottom"};function ie(e){return e.replace(/left|right|bottom|top/g,(function(e){return ne[e]}))}var oe={start:"end",end:"start"};function re(e){return e.replace(/start|end/g,(function(e){return oe[e]}))}function se(e){var t=A(e);return{scrollLeft:t.pageXOffset,scrollTop:t.pageYOffset}}function ae(e){return B(z(e)).left+se(e).scrollLeft}function ce(e){var t=I(e),n=t.overflow,i=t.overflowX,o=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+i)}function le(e){return["html","body","#document"].indexOf(x(e))>=0?e.ownerDocument.body:L(e)&&ce(e)?e:le(q(e))}function fe(e,t){var n;void 0===t&&(t=[]);var i=le(e),o=i===(null==(n=e.ownerDocument)?void 0:n.body),r=A(i),s=o?[r].concat(r.visualViewport||[],ce(i)?i:[]):i,a=t.concat(s);return o?a:a.concat(fe(q(s)))}function ue(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function de(e,t,n){return t===l?ue(function(e,t){var n=A(e),i=z(e),o=n.visualViewport,r=i.clientWidth,s=i.clientHeight,a=0,c=0;if(o){r=o.width,s=o.height;var l=M();(l||!l&&"fixed"===t)&&(a=o.offsetLeft,c=o.offsetTop)}return{width:r,height:s,x:a+ae(e),y:c}}(e,n)):C(t)?function(e,t){var n=B(e,!1,"fixed"===t);return n.top=n.top+e.clientTop,n.left=n.left+e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}(t,n):ue(function(e){var t,n=z(e),i=se(e),o=null==(t=e.ownerDocument)?void 0:t.body,r=D(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=D(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),a=-i.scrollLeft+ae(e),c=-i.scrollTop;return"rtl"===I(o||n).direction&&(a+=D(n.clientWidth,o?o.clientWidth:0)-r),{width:r,height:s,x:a,y:c}}(z(e)))}function pe(e,t,n,i){var o="clippingParents"===t?function(e){var t=fe(q(e)),n=["absolute","fixed"].indexOf(I(e).position)>=0&&L(e)?V(e):e;return C(n)?t.filter((function(e){return C(e)&&H(e,n)&&"body"!==x(e)})):[]}(e):[].concat(t),r=[].concat(o,[n]),s=r[0],a=r.reduce((function(t,n){var o=de(e,n,i);return t.top=D(o.top,t.top),t.right=k(o.right,t.right),t.bottom=k(o.bottom,t.bottom),t.left=D(o.left,t.left),t}),de(e,s,i));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function he(o){var r,c=o.reference,l=o.element,f=o.placement,u=f?S(f):null,d=f?G(f):null,p=c.x+c.width/2-l.width/2,h=c.y+c.height/2-l.height/2;switch(u){case e:r={x:p,y:c.y-l.height};break;case t:r={x:p,y:c.y+c.height};break;case n:r={x:c.x+c.width,y:h};break;case i:r={x:c.x-l.width,y:h};break;default:r={x:c.x,y:c.y}}var m=u?Y(u):null;if(null!=m){var g="y"===m?"height":"width";switch(d){case s:r[m]=r[m]-(c[g]/2-l[g]/2);break;case a:r[m]=r[m]+(c[g]/2-l[g]/2)}}return r}function me(i,o){void 0===o&&(o={});var s=o,a=s.placement,d=void 0===a?i.placement:a,p=s.strategy,h=void 0===p?i.strategy:p,m=s.boundary,g=void 0===m?c:m,v=s.rootBoundary,b=void 0===v?l:v,y=s.elementContext,_=void 0===y?f:y,w=s.altBoundary,E=void 0!==w&&w,O=s.padding,x=void 0===O?0:O,A=K("number"!=typeof x?x:Q(x,r)),L=_===f?u:f,T=i.rects.popper,j=i.elements[E?L:_],S=pe(C(j)?j:j.contextElement||z(i.elements.popper),g,b,h),D=B(i.elements.reference),k=he({reference:D,element:T,strategy:"absolute",placement:d}),P=ue(Object.assign({},T,k)),N=_===f?P:D,M={top:S.top-N.top+A.top,bottom:N.bottom-S.bottom+A.bottom,left:S.left-N.left+A.left,right:N.right-S.right+A.right},F=i.modifiersData.offset;if(_===f&&F){var H=F[d];Object.keys(M).forEach((function(i){var o=[n,t].indexOf(i)>=0?1:-1,r=[e,t].indexOf(i)>=0?"y":"x";M[i]+=H[r]*o}))}return M}function ge(e,t){void 0===t&&(t={});var n=t,i=n.placement,o=n.boundary,s=n.rootBoundary,a=n.padding,c=n.flipVariations,l=n.allowedAutoPlacements,f=void 0===l?p:l,u=G(i),h=u?c?d:d.filter((function(e){return G(e)===u})):r,m=h.filter((function(e){return f.indexOf(e)>=0}));0===m.length&&(m=h);var g=m.reduce((function(t,n){return t[n]=me(e,{placement:n,boundary:o,rootBoundary:s,padding:a})[S(n)],t}),{});return Object.keys(g).sort((function(e,t){return g[e]-g[t]}))}var ve={name:"flip",enabled:!0,phase:"main",fn:function(r){var a=r.state,c=r.options,l=r.name;if(!a.modifiersData[l]._skip){for(var f=c.mainAxis,u=void 0===f||f,d=c.altAxis,p=void 0===d||d,h=c.fallbackPlacements,m=c.padding,g=c.boundary,v=c.rootBoundary,b=c.altBoundary,y=c.flipVariations,_=void 0===y||y,w=c.allowedAutoPlacements,E=a.options.placement,O=S(E),x=h||(O===E||!_?[ie(E)]:function(e){if(S(e)===o)return[];var t=ie(e);return[re(e),t,re(t)]}(E)),A=[E].concat(x).reduce((function(e,t){return e.concat(S(t)===o?ge(a,{placement:t,boundary:g,rootBoundary:v,padding:m,flipVariations:_,allowedAutoPlacements:w}):t)}),[]),C=a.rects.reference,L=a.rects.popper,T=new Map,j=!0,D=A[0],k=0;k=0,F=B?"width":"height",H=me(a,{placement:P,boundary:g,rootBoundary:v,altBoundary:b,padding:m}),I=B?M?n:i:M?t:e;C[F]>L[F]&&(I=ie(I));var W=ie(I),z=[];if(u&&z.push(H[N]<=0),p&&z.push(H[I]<=0,H[W]<=0),z.every((function(e){return e}))){D=P,j=!1;break}T.set(P,z)}if(j)for(var q=function(e){var t=A.find((function(t){var n=T.get(t);if(n)return n.slice(0,e).every((function(e){return e}))}));if(t)return D=t,"break"},R=_?3:1;R>0;R--){if("break"===q(R))break}a.placement!==D&&(a.modifiersData[l]._skip=!0,a.placement=D,a.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function be(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function ye(o){return[e,n,t,i].some((function(e){return o[e]>=0}))}var _e={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(e){var t=e.state,n=e.name,i=t.rects.reference,o=t.rects.popper,r=t.modifiersData.preventOverflow,s=me(t,{elementContext:"reference"}),a=me(t,{altBoundary:!0}),c=be(s,i),l=be(a,o,r),f=ye(c),u=ye(l);t.modifiersData[n]={referenceClippingOffsets:c,popperEscapeOffsets:l,isReferenceHidden:f,hasPopperEscaped:u},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":f,"data-popper-escaped":u})}};var we={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var o=t.state,r=t.options,s=t.name,a=r.offset,c=void 0===a?[0,0]:a,l=p.reduce((function(t,r){return t[r]=function(t,o,r){var s=S(t),a=[i,e].indexOf(s)>=0?-1:1,c="function"==typeof r?r(Object.assign({},o,{placement:t})):r,l=c[0],f=c[1];return l=l||0,f=(f||0)*a,[i,n].indexOf(s)>=0?{x:f,y:l}:{x:l,y:f}}(r,o.rects,c),t}),{}),f=l[o.placement],u=f.x,d=f.y;null!=o.modifiersData.popperOffsets&&(o.modifiersData.popperOffsets.x+=u,o.modifiersData.popperOffsets.y+=d),o.modifiersData[s]=l}};var Ee={name:"popperOffsets",enabled:!0,phase:"read",fn:function(e){var t=e.state,n=e.name;t.modifiersData[n]=he({reference:t.rects.reference,element:t.rects.popper,strategy:"absolute",placement:t.placement})},data:{}};var Oe={name:"preventOverflow",enabled:!0,phase:"main",fn:function(o){var r=o.state,a=o.options,c=o.name,l=a.mainAxis,f=void 0===l||l,u=a.altAxis,d=void 0!==u&&u,p=a.boundary,h=a.rootBoundary,m=a.altBoundary,g=a.padding,v=a.tether,b=void 0===v||v,y=a.tetherOffset,_=void 0===y?0:y,w=me(r,{boundary:p,rootBoundary:h,padding:g,altBoundary:m}),E=S(r.placement),O=G(r.placement),x=!O,A=Y(E),C="x"===A?"y":"x",L=r.modifiersData.popperOffsets,T=r.rects.reference,j=r.rects.popper,P="function"==typeof _?_(Object.assign({},r.rects,{placement:r.placement})):_,N="number"==typeof P?{mainAxis:P,altAxis:P}:Object.assign({mainAxis:0,altAxis:0},P),M=r.modifiersData.offset?r.modifiersData.offset[r.placement]:null,B={x:0,y:0};if(L){if(f){var H,I="y"===A?e:i,W="y"===A?t:n,z="y"===A?"height":"width",q=L[A],R=q+w[I],K=q-w[W],Q=b?-j[z]/2:0,$=O===s?T[z]:j[z],X=O===s?-j[z]:-T[z],J=r.elements.arrow,Z=b&&J?F(J):{width:0,height:0},ee=r.modifiersData["arrow#persistent"]?r.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},te=ee[I],ne=ee[W],ie=U(0,T[z],Z[z]),oe=x?T[z]/2-Q-ie-te-N.mainAxis:$-ie-te-N.mainAxis,re=x?-T[z]/2+Q+ie+ne+N.mainAxis:X+ie+ne+N.mainAxis,se=r.elements.arrow&&V(r.elements.arrow),ae=se?"y"===A?se.clientTop||0:se.clientLeft||0:0,ce=null!=(H=null==M?void 0:M[A])?H:0,le=q+re-ce,fe=U(b?k(R,q+oe-ce-ae):R,q,b?D(K,le):K);L[A]=fe,B[A]=fe-q}if(d){var ue,de="x"===A?e:i,pe="x"===A?t:n,he=L[C],ge="y"===C?"height":"width",ve=he+w[de],be=he-w[pe],ye=-1!==[e,i].indexOf(E),_e=null!=(ue=null==M?void 0:M[C])?ue:0,we=ye?ve:he-T[ge]-j[ge]-_e+N.altAxis,Ee=ye?he+T[ge]+j[ge]-_e-N.altAxis:be,Oe=b&&ye?function(e,t,n){var i=U(e,t,n);return i>n?n:i}(we,he,Ee):U(b?we:ve,he,b?Ee:be);L[C]=Oe,B[C]=Oe-he}r.modifiersData[c]=B}},requiresIfExists:["offset"]};function xe(e,t,n){void 0===n&&(n=!1);var i,o,r=L(t),s=L(t)&&function(e){var t=e.getBoundingClientRect(),n=P(t.width)/e.offsetWidth||1,i=P(t.height)/e.offsetHeight||1;return 1!==n||1!==i}(t),a=z(t),c=B(e,s,n),l={scrollLeft:0,scrollTop:0},f={x:0,y:0};return(r||!r&&!n)&&(("body"!==x(t)||ce(a))&&(l=(i=t)!==A(i)&&L(i)?{scrollLeft:(o=i).scrollLeft,scrollTop:o.scrollTop}:se(i)),L(t)?((f=B(t,!0)).x+=t.clientLeft,f.y+=t.clientTop):a&&(f.x=ae(a))),{x:c.left+l.scrollLeft-f.x,y:c.top+l.scrollTop-f.y,width:c.width,height:c.height}}function Ae(e){var t=new Map,n=new Set,i=[];function o(e){n.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach((function(e){if(!n.has(e)){var i=t.get(e);i&&o(i)}})),i.push(e)}return e.forEach((function(e){t.set(e.name,e)})),e.forEach((function(e){n.has(e.name)||o(e)})),i}var Ce={placement:"bottom",modifiers:[],strategy:"absolute"};function Le(){for(var e=arguments.length,t=new Array(e),n=0;nPe.has(e)&&Pe.get(e).get(t)||null,remove(e,t){if(!Pe.has(e))return;const n=Pe.get(e);n.delete(t),0===n.size&&Pe.delete(e)}};const Me="transitionend",Be=e=>(e&&window.CSS&&window.CSS.escape&&(e=e.replace(/#([^\s"#']+)/g,((e,t)=>"#".concat(CSS.escape(t))))),e),Fe=e=>!(!e||"object"!=typeof e)&&(void 0!==e.jquery&&(e=e[0]),void 0!==e.nodeType),He=e=>Fe(e)?e.jquery?e[0]:e:"string"==typeof e&&e.length>0?document.querySelector(Be(e)):null,Ie=e=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof e.getRootNode){const t=e.getRootNode();return t instanceof ShadowRoot?t:null}return e instanceof ShadowRoot?e:e.parentNode?Ie(e.parentNode):null},We=()=>{},ze=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,qe=[],Re=()=>"rtl"===document.documentElement.dir,Ve=e=>{var t;t=()=>{const t=ze();if(t){const n=e.NAME,i=t.fn[n];t.fn[n]=e.jQueryInterface,t.fn[n].Constructor=e,t.fn[n].noConflict=()=>(t.fn[n]=i,e.jQueryInterface)}},"loading"===document.readyState?(qe.length||document.addEventListener("DOMContentLoaded",(()=>{for(const e of qe)e()})),qe.push(t)):t()},Ye=function(e){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e;return"function"==typeof e?e(...arguments.length>1&&void 0!==arguments[1]?arguments[1]:[]):t},Ue=function(e,t){if(!(!(arguments.length>2&&void 0!==arguments[2])||arguments[2]))return void Ye(e);const n=(e=>{if(!e)return 0;let{transitionDuration:t,transitionDelay:n}=window.getComputedStyle(e);const i=Number.parseFloat(t),o=Number.parseFloat(n);return i||o?(t=t.split(",")[0],n=n.split(",")[0],1e3*(Number.parseFloat(t)+Number.parseFloat(n))):0})(t)+5;let i=!1;const o=n=>{let{target:r}=n;r===t&&(i=!0,t.removeEventListener(Me,o),Ye(e))};t.addEventListener(Me,o),setTimeout((()=>{i||t.dispatchEvent(new Event(Me))}),n)},Ke=/[^.]*(?=\..*)\.|.*/,Qe=/\..*/,$e=/::\d+$/,Ge={};let Xe=1;const Je={mouseenter:"mouseover",mouseleave:"mouseout"},Ze=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function et(e,t){return t&&"".concat(t,"::").concat(Xe++)||e.uidEvent||Xe++}function tt(e){const t=et(e);return e.uidEvent=t,Ge[t]=Ge[t]||{},Ge[t]}function nt(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;return Object.values(e).find((e=>e.callable===t&&e.delegationSelector===n))}function it(e,t,n){const i="string"==typeof t,o=i?n:t||n;let r=at(e);return Ze.has(r)||(r=e),[i,o,r]}function ot(e,t,n,i,o){if("string"!=typeof t||!e)return;let[r,s,a]=it(t,n,i);if(t in Je){const e=e=>function(t){if(!t.relatedTarget||t.relatedTarget!==t.delegateTarget&&!t.delegateTarget.contains(t.relatedTarget))return e.call(this,t)};s=e(s)}const c=tt(e),l=c[a]||(c[a]={}),f=nt(l,s,r?n:null);if(f)return void(f.oneOff=f.oneOff&&o);const u=et(s,t.replace(Ke,"")),d=r?function(e,t,n){return function i(o){const r=e.querySelectorAll(t);for(let{target:s}=o;s&&s!==this;s=s.parentNode)for(const a of r)if(a===s)return lt(o,{delegateTarget:s}),i.oneOff&&ct.off(e,o.type,t,n),n.apply(s,[o])}}(e,n,s):function(e,t){return function n(i){return lt(i,{delegateTarget:e}),n.oneOff&&ct.off(e,i.type,t),t.apply(e,[i])}}(e,s);d.delegationSelector=r?n:null,d.callable=s,d.oneOff=o,d.uidEvent=u,l[u]=d,e.addEventListener(a,d,r)}function rt(e,t,n,i,o){const r=nt(t[n],i,o);r&&(e.removeEventListener(n,r,Boolean(o)),delete t[n][r.uidEvent])}function st(e,t,n,i){const o=t[n]||{};for(const[r,s]of Object.entries(o))r.includes(i)&&rt(e,t,n,s.callable,s.delegationSelector)}function at(e){return e=e.replace(Qe,""),Je[e]||e}const ct={on(e,t,n,i){ot(e,t,n,i,!1)},one(e,t,n,i){ot(e,t,n,i,!0)},off(e,t,n,i){if("string"!=typeof t||!e)return;const[o,r,s]=it(t,n,i),a=s!==t,c=tt(e),l=c[s]||{},f=t.startsWith(".");if(void 0===r){if(f)for(const n of Object.keys(c))st(e,c,n,t.slice(1));for(const[n,i]of Object.entries(l)){const o=n.replace($e,"");a&&!t.includes(o)||rt(e,c,s,i.callable,i.delegationSelector)}}else{if(!Object.keys(l).length)return;rt(e,c,s,r,o?n:null)}},trigger(e,t,n){if("string"!=typeof t||!e)return null;const i=ze();let o=null,r=!0,s=!0,a=!1;t!==at(t)&&i&&(o=i.Event(t,n),i(e).trigger(o),r=!o.isPropagationStopped(),s=!o.isImmediatePropagationStopped(),a=o.isDefaultPrevented());const c=lt(new Event(t,{bubbles:r,cancelable:!0}),n);return a&&c.preventDefault(),s&&e.dispatchEvent(c),c.defaultPrevented&&o&&o.preventDefault(),c}};function lt(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(const[n,i]of Object.entries(t))try{e[n]=i}catch{Object.defineProperty(e,n,{configurable:!0,get:()=>i})}return e}function ft(e){if("true"===e)return!0;if("false"===e)return!1;if(e===Number(e).toString())return Number(e);if(""===e||"null"===e)return null;if("string"!=typeof e)return e;try{return JSON.parse(decodeURIComponent(e))}catch{return e}}function ut(e){return e.replace(/[A-Z]/g,(e=>"-".concat(e.toLowerCase())))}const dt={setDataAttribute(e,t,n){e.setAttribute("data-bs-".concat(ut(t)),n)},removeDataAttribute(e,t){e.removeAttribute("data-bs-".concat(ut(t)))},getDataAttributes(e){if(!e)return{};const t={},n=Object.keys(e.dataset).filter((e=>e.startsWith("bs")&&!e.startsWith("bsConfig")));for(const i of n){let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),t[n]=ft(e.dataset[i])}return t},getDataAttribute:(e,t)=>ft(e.getAttribute("data-bs-".concat(ut(t))))};class pt{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(e){return e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e}_mergeConfigObj(e,t){const n=Fe(t)?dt.getDataAttribute(t,"config"):{};return{...this.constructor.Default,..."object"==typeof n?n:{},...Fe(t)?dt.getDataAttributes(t):{},..."object"==typeof e?e:{}}}_typeCheckConfig(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.constructor.DefaultType;for(const[i,o]of Object.entries(t)){const t=e[i],r=Fe(t)?"element":null==(n=t)?"".concat(n):Object.prototype.toString.call(n).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(o).test(r))throw new TypeError("".concat(this.constructor.NAME.toUpperCase(),': Option "').concat(i,'" provided type "').concat(r,'" but expected type "').concat(o,'".'))}var n}}class ht extends pt{constructor(e,t){super(),(e=He(e))&&(this._element=e,this._config=this._getConfig(t),Ne.set(this._element,this.constructor.DATA_KEY,this))}dispose(){Ne.remove(this._element,this.constructor.DATA_KEY),ct.off(this._element,this.constructor.EVENT_KEY);for(const e of Object.getOwnPropertyNames(this))this[e]=null}_queueCallback(e,t){Ue(e,t,!(arguments.length>2&&void 0!==arguments[2])||arguments[2])}_getConfig(e){return e=this._mergeConfigObj(e,this._element),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}static getInstance(e){return Ne.get(He(e),this.DATA_KEY)}static getOrCreateInstance(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return this.getInstance(e)||new this(e,"object"==typeof t?t:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return"bs.".concat(this.NAME)}static get EVENT_KEY(){return".".concat(this.DATA_KEY)}static eventName(e){return"".concat(e).concat(this.EVENT_KEY)}}const mt={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},gt=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),vt=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,bt=(e,t)=>{const n=e.nodeName.toLowerCase();return t.includes(n)?!gt.has(n)||Boolean(vt.test(e.nodeValue)):t.filter((e=>e instanceof RegExp)).some((e=>e.test(n)))};const yt=e=>{let t=e.getAttribute("data-bs-target");if(!t||"#"===t){let n=e.getAttribute("href");if(!n||!n.includes("#")&&!n.startsWith("."))return null;n.includes("#")&&!n.startsWith("#")&&(n="#".concat(n.split("#")[1])),t=n&&"#"!==n?n.trim():null}return t?t.split(",").map((e=>Be(e))).join(","):null},_t={find(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return[].concat(...Element.prototype.querySelectorAll.call(t,e))},findOne(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return Element.prototype.querySelector.call(t,e)},children:(e,t)=>[].concat(...e.children).filter((e=>e.matches(t))),parents(e,t){const n=[];let i=e.parentNode.closest(t);for(;i;)n.push(i),i=i.parentNode.closest(t);return n},prev(e,t){let n=e.previousElementSibling;for(;n;){if(n.matches(t))return[n];n=n.previousElementSibling}return[]},next(e,t){let n=e.nextElementSibling;for(;n;){if(n.matches(t))return[n];n=n.nextElementSibling}return[]},focusableChildren(e){const t=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((e=>"".concat(e,':not([tabindex^="-"])'))).join(",");return this.find(t,e).filter((e=>!(e=>!e||e.nodeType!==Node.ELEMENT_NODE||!!e.classList.contains("disabled")||(void 0!==e.disabled?e.disabled:e.hasAttribute("disabled")&&"false"!==e.getAttribute("disabled")))(e)&&(e=>{if(!Fe(e)||0===e.getClientRects().length)return!1;const t="visible"===getComputedStyle(e).getPropertyValue("visibility"),n=e.closest("details:not([open])");if(!n)return t;if(n!==e){const t=e.closest("summary");if(t&&t.parentNode!==n)return!1;if(null===t)return!1}return t})(e)))},getSelectorFromElement(e){const t=yt(e);return t&&_t.findOne(t)?t:null},getElementFromSelector(e){const t=yt(e);return t?_t.findOne(t):null},getMultipleElementsFromSelector(e){const t=yt(e);return t?_t.find(t):[]}},wt={allowList:mt,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Et={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Ot={entry:"(string|element|function|null)",selector:"(string|element)"};class xt extends pt{constructor(e){super(),this._config=this._getConfig(e)}static get Default(){return wt}static get DefaultType(){return Et}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((e=>this._resolvePossibleFunction(e))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(e){return this._checkContent(e),this._config.content={...this._config.content,...e},this}toHtml(){const e=document.createElement("div");e.innerHTML=this._maybeSanitize(this._config.template);for(const[t,n]of Object.entries(this._config.content))this._setContent(e,n,t);const t=e.children[0],n=this._resolvePossibleFunction(this._config.extraClass);return n&&t.classList.add(...n.split(" ")),t}_typeCheckConfig(e){super._typeCheckConfig(e),this._checkContent(e.content)}_checkContent(e){for(const[t,n]of Object.entries(e))super._typeCheckConfig({selector:t,entry:n},Ot)}_setContent(e,t,n){const i=_t.findOne(n,e);i&&((t=this._resolvePossibleFunction(t))?Fe(t)?this._putElementInTemplate(He(t),i):this._config.html?i.innerHTML=this._maybeSanitize(t):i.textContent=t:i.remove())}_maybeSanitize(e){return this._config.sanitize?function(e,t,n){if(!e.length)return e;if(n&&"function"==typeof n)return n(e);const i=(new window.DOMParser).parseFromString(e,"text/html"),o=[].concat(...i.body.querySelectorAll("*"));for(const e of o){const n=e.nodeName.toLowerCase();if(!Object.keys(t).includes(n)){e.remove();continue}const i=[].concat(...e.attributes),o=[].concat(t["*"]||[],t[n]||[]);for(const t of i)bt(t,o)||e.removeAttribute(t.nodeName)}return i.body.innerHTML}(e,this._config.allowList,this._config.sanitizeFn):e}_resolvePossibleFunction(e){return Ye(e,[this])}_putElementInTemplate(e,t){if(this._config.html)return t.innerHTML="",void t.append(e);t.textContent=e.textContent}}const At=new Set(["sanitize","allowList","sanitizeFn"]),Ct="fade",Lt="show",Tt=".".concat("modal"),jt="hide.bs.modal",St="hover",Dt="focus",kt={AUTO:"auto",TOP:"top",RIGHT:Re()?"left":"right",BOTTOM:"bottom",LEFT:Re()?"right":"left"},Pt={allowList:mt,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Nt={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Mt extends ht{constructor(e,t){if(void 0===ke)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(e,t),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Pt}static get DefaultType(){return Nt}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),ct.off(this._element.closest(Tt),jt,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const e=ct.trigger(this._element,this.constructor.eventName("show")),t=(Ie(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(e.defaultPrevented||!t)return;this._disposePopper();const n=this._getTipElement();this._element.setAttribute("aria-describedby",n.getAttribute("id"));const{container:i}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(i.append(n),ct.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(n),n.classList.add(Lt),"ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))ct.on(e,"mouseover",We);this._queueCallback((()=>{ct.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(!this._isShown())return;if(ct.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented)return;if(this._getTipElement().classList.remove(Lt),"ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))ct.off(e,"mouseover",We);this._activeTrigger.click=!1,this._activeTrigger[Dt]=!1,this._activeTrigger[St]=!1,this._isHovered=null;this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),ct.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(e){const t=this._getTemplateFactory(e).toHtml();if(!t)return null;t.classList.remove(Ct,Lt),t.classList.add("bs-".concat(this.constructor.NAME,"-auto"));const n=(e=>{do{e+=Math.floor(1e6*Math.random())}while(document.getElementById(e));return e})(this.constructor.NAME).toString();return t.setAttribute("id",n),this._isAnimated()&&t.classList.add(Ct),t}setContent(e){this._newContent=e,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(e){return this._templateFactory?this._templateFactory.changeContent(e):this._templateFactory=new xt({...this._config,content:e,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(e){return this.constructor.getOrCreateInstance(e.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ct)}_isShown(){return this.tip&&this.tip.classList.contains(Lt)}_createPopper(e){const t=Ye(this._config.placement,[this,e,this._element]),n=kt[t.toUpperCase()];return De(this._element,e,this._getPopperConfig(n))}_getOffset(){const{offset:e}=this._config;return"string"==typeof e?e.split(",").map((e=>Number.parseInt(e,10))):"function"==typeof e?t=>e(t,this._element):e}_resolvePossibleFunction(e){return Ye(e,[this._element])}_getPopperConfig(e){const t={placement:e,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:".".concat(this.constructor.NAME,"-arrow")}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:e=>{this._getTipElement().setAttribute("data-popper-placement",e.state.placement)}}]};return{...t,...Ye(this._config.popperConfig,[t])}}_setListeners(){const e=this._config.trigger.split(" ");for(const t of e)if("click"===t)ct.on(this._element,this.constructor.eventName("click"),this._config.selector,(e=>{this._initializeOnDelegatedTarget(e).toggle()}));else if("manual"!==t){const e=t===St?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),n=t===St?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");ct.on(this._element,e,this._config.selector,(e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusin"===e.type?Dt:St]=!0,t._enter()})),ct.on(this._element,n,this._config.selector,(e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusout"===e.type?Dt:St]=t._element.contains(e.relatedTarget),t._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},ct.on(this._element.closest(Tt),jt,this._hideModalHandler)}_fixTitle(){const e=this._element.getAttribute("title");e&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",e),this._element.setAttribute("data-bs-original-title",e),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(e,t){clearTimeout(this._timeout),this._timeout=setTimeout(e,t)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(e){const t=dt.getDataAttributes(this._element);for(const e of Object.keys(t))At.has(e)&&delete t[e];return e={...t,..."object"==typeof e&&e?e:{}},e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e.container=!1===e.container?document.body:He(e.container),"number"==typeof e.delay&&(e.delay={show:e.delay,hide:e.delay}),"number"==typeof e.title&&(e.title=e.title.toString()),"number"==typeof e.content&&(e.content=e.content.toString()),e}_getDelegateConfig(){const e={};for(const[t,n]of Object.entries(this._config))this.constructor.Default[t]!==n&&(e[t]=n);return e.selector=!1,e.trigger="manual",e}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(e){return this.each((function(){const t=Mt.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError('No method named "'.concat(e,'"'));t[e]()}}))}}Ve(Mt);const Bt=document.getElementById("mode-toggle");function Ft(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var i=n.call(e,t||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function Ht(e,t,n){return(t=Ft(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}const It="sidebar-display";class Wt{static toggle(){!1===Wt.isExpanded?document.body.setAttribute(It,""):document.body.removeAttribute(It),Wt.isExpanded=!Wt.isExpanded}}Ht(Wt,"isExpanded",!1);const zt=document.getElementById("sidebar-trigger"),qt=document.getElementById("search-trigger"),Rt=document.getElementById("search-cancel"),Vt=document.querySelectorAll("#main-wrapper>.container>.row"),Yt=document.getElementById("topbar-title"),Ut=document.getElementById("search"),Kt=document.getElementById("search-result-wrapper"),Qt=document.getElementById("search-results"),$t=document.getElementById("search-input"),Gt=document.getElementById("search-hints"),Xt="d-block",Jt="d-none",Zt="input-focus",en="d-flex";class tn{static on(){zt.classList.add(Jt),Yt.classList.add(Jt),qt.classList.add(Jt),Ut.classList.add(en),Rt.classList.add(Xt)}static off(){Rt.classList.remove(Xt),Ut.classList.remove(en),zt.classList.remove(Jt),Yt.classList.remove(Jt),qt.classList.remove(Jt)}}class nn{static on(){this.resultVisible||(Kt.classList.remove(Jt),Vt.forEach((e=>{e.classList.add(Jt)})),this.resultVisible=!0)}static off(){this.resultVisible&&(Qt.innerHTML="",Gt.classList.contains(Jt)&&Gt.classList.remove(Jt),Kt.classList.add(Jt),Vt.forEach((e=>{e.classList.remove(Jt)})),$t.textContent="",this.resultVisible=!1)}}function on(){return Rt.classList.contains(Xt)}Ht(nn,"resultVisible",!1);const rn=".".concat("bs.collapse"),sn="show".concat(rn),an="shown".concat(rn),cn="hide".concat(rn),ln="hidden".concat(rn),fn="click".concat(rn).concat(".data-api"),un="show",dn="collapse",pn="collapsing",hn=":scope .".concat(dn," .").concat(dn),mn='[data-bs-toggle="collapse"]',gn={parent:null,toggle:!0},vn={parent:"(null|element)",toggle:"boolean"};class bn extends ht{constructor(e,t){super(e,t),this._isTransitioning=!1,this._triggerArray=[];const n=_t.find(mn);for(const e of n){const t=_t.getSelectorFromElement(e),n=_t.find(t).filter((e=>e===this._element));null!==t&&n.length&&this._triggerArray.push(e)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return gn}static get DefaultType(){return vn}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let e=[];if(this._config.parent&&(e=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((e=>e!==this._element)).map((e=>bn.getOrCreateInstance(e,{toggle:!1})))),e.length&&e[0]._isTransitioning)return;if(ct.trigger(this._element,sn).defaultPrevented)return;for(const t of e)t.hide();const t=this._getDimension();this._element.classList.remove(dn),this._element.classList.add(pn),this._element.style[t]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const n=t[0].toUpperCase()+t.slice(1),i="scroll".concat(n);this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn,un),this._element.style[t]="",ct.trigger(this._element,an)}),this._element,!0),this._element.style[t]="".concat(this._element[i],"px")}hide(){if(this._isTransitioning||!this._isShown())return;if(ct.trigger(this._element,cn).defaultPrevented)return;const e=this._getDimension();this._element.style[e]="".concat(this._element.getBoundingClientRect()[e],"px"),this._element.offsetHeight,this._element.classList.add(pn),this._element.classList.remove(dn,un);for(const e of this._triggerArray){const t=_t.getElementFromSelector(e);t&&!this._isShown(t)&&this._addAriaAndCollapsedClass([e],!1)}this._isTransitioning=!0;this._element.style[e]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn),ct.trigger(this._element,ln)}),this._element,!0)}_isShown(){return(arguments.length>0&&void 0!==arguments[0]?arguments[0]:this._element).classList.contains(un)}_configAfterMerge(e){return e.toggle=Boolean(e.toggle),e.parent=He(e.parent),e}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const e=this._getFirstLevelChildren(mn);for(const t of e){const e=_t.getElementFromSelector(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}}_getFirstLevelChildren(e){const t=_t.find(hn,this._config.parent);return _t.find(e,this._config.parent).filter((e=>!t.includes(e)))}_addAriaAndCollapsedClass(e,t){if(e.length)for(const n of e)n.classList.toggle("collapsed",!t),n.setAttribute("aria-expanded",t)}static jQueryInterface(e){const t={};return"string"==typeof e&&/show|hide/.test(e)&&(t.toggle=!1),this.each((function(){const n=bn.getOrCreateInstance(this,t);if("string"==typeof e){if(void 0===n[e])throw new TypeError('No method named "'.concat(e,'"'));n[e]()}}))}}ct.on(document,fn,mn,(function(e){("A"===e.target.tagName||e.delegateTarget&&"A"===e.delegateTarget.tagName)&&e.preventDefault();for(const e of _t.getMultipleElementsFromSelector(this))bn.getOrCreateInstance(e,{toggle:!1}).toggle()})),Ve(bn);const yn=document.getElementsByClassName("collapse");!function(){const e=document.getElementById("back-to-top");window.addEventListener("scroll",(()=>{window.scrollY>50?e.classList.add("show"):e.classList.remove("show")})),e.addEventListener("click",(()=>{window.scrollTo({top:0})}))}(),[...document.querySelectorAll('[data-bs-toggle="tooltip"]')].map((e=>new Mt(e))),Bt&&Bt.addEventListener("click",(()=>{modeToggle.flipMode()})),document.getElementById("sidebar-trigger").addEventListener("click",Wt.toggle),document.getElementById("mask").addEventListener("click",Wt.toggle),qt.addEventListener("click",(()=>{tn.on(),nn.on(),$t.focus()})),Rt.addEventListener("click",(()=>{tn.off(),nn.off()})),$t.addEventListener("focus",(()=>{Ut.classList.add(Zt)})),$t.addEventListener("focusout",(()=>{Ut.classList.remove(Zt)})),$t.addEventListener("input",(()=>{""===$t.value?on()?Gt.classList.remove(Jt):nn.off():(nn.on(),on()&&Gt.classList.add(Jt))})),[...yn].forEach((e=>{const t="h_"+e.id.substring(2),n=document.getElementById(t);e.addEventListener("hide.bs.collapse",(()=>{n&&(n.querySelector(".far.fa-folder-open").className="far fa-folder fa-fw",n.querySelector(".fas.fa-angle-down").classList.add("rotate"),n.classList.remove("hide-border-bottom"))})),e.addEventListener("show.bs.collapse",(()=>{n&&(n.querySelector(".far.fa-folder").className="far fa-folder-open fa-fw",n.querySelector(".fas.fa-angle-down").classList.remove("rotate"),n.classList.add("hide-border-bottom"))}))}))}(); diff --git a/assets/js/dist/commons.min.js b/assets/js/dist/commons.min.js new file mode 100644 index 0000000..f1348e5 --- /dev/null +++ b/assets/js/dist/commons.min.js @@ -0,0 +1,4 @@ +/*! + * jekyll-theme-chirpy v7.1.1 | © 2019 Cotes Chung | MIT Licensed | https://github.com/cotes2020/jekyll-theme-chirpy/ + */ +!function(){"use strict";var e="top",t="bottom",n="right",i="left",o="auto",r=[e,t,n,i],s="start",a="end",c="clippingParents",l="viewport",u="popper",f="reference",d=r.reduce((function(e,t){return e.concat([t+"-"+s,t+"-"+a])}),[]),p=[].concat(r,[o]).reduce((function(e,t){return e.concat([t,t+"-"+s,t+"-"+a])}),[]),h="beforeRead",m="read",g="afterRead",v="beforeMain",b="main",y="afterMain",_="beforeWrite",w="write",E="afterWrite",O=[h,m,g,v,b,y,_,w,E];function x(e){return e?(e.nodeName||"").toLowerCase():null}function A(e){if(null==e)return window;if("[object Window]"!==e.toString()){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function T(e){return e instanceof A(e).Element||e instanceof Element}function C(e){return e instanceof A(e).HTMLElement||e instanceof HTMLElement}function L(e){return"undefined"!=typeof ShadowRoot&&(e instanceof A(e).ShadowRoot||e instanceof ShadowRoot)}var j={name:"applyStyles",enabled:!0,phase:"write",fn:function(e){var t=e.state;Object.keys(t.elements).forEach((function(e){var n=t.styles[e]||{},i=t.attributes[e]||{},o=t.elements[e];C(o)&&x(o)&&(Object.assign(o.style,n),Object.keys(i).forEach((function(e){var t=i[e];!1===t?o.removeAttribute(e):o.setAttribute(e,!0===t?"":t)})))}))},effect:function(e){var t=e.state,n={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.styles=n,t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach((function(e){var i=t.elements[e],o=t.attributes[e]||{},r=Object.keys(t.styles.hasOwnProperty(e)?t.styles[e]:n[e]).reduce((function(e,t){return e[t]="",e}),{});C(i)&&x(i)&&(Object.assign(i.style,r),Object.keys(o).forEach((function(e){i.removeAttribute(e)})))}))}},requires:["computeStyles"]};function D(e){return e.split("-")[0]}var S=Math.max,k=Math.min,P=Math.round;function M(){var e=navigator.userAgentData;return null!=e&&e.brands&&Array.isArray(e.brands)?e.brands.map((function(e){return e.brand+"/"+e.version})).join(" "):navigator.userAgent}function N(){return!/^((?!chrome|android).)*safari/i.test(M())}function B(e,t,n){void 0===t&&(t=!1),void 0===n&&(n=!1);var i=e.getBoundingClientRect(),o=1,r=1;t&&C(e)&&(o=e.offsetWidth>0&&P(i.width)/e.offsetWidth||1,r=e.offsetHeight>0&&P(i.height)/e.offsetHeight||1);var s=(T(e)?A(e):window).visualViewport,a=!N()&&n,c=(i.left+(a&&s?s.offsetLeft:0))/o,l=(i.top+(a&&s?s.offsetTop:0))/r,u=i.width/o,f=i.height/r;return{width:u,height:f,top:l,right:c+u,bottom:l+f,left:c,x:c,y:l}}function H(e){var t=B(e),n=e.offsetWidth,i=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-i)<=1&&(i=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:i}}function F(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&L(n)){var i=t;do{if(i&&e.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function I(e){return A(e).getComputedStyle(e)}function W(e){return["table","td","th"].indexOf(x(e))>=0}function z(e){return((T(e)?e.ownerDocument:e.document)||window.document).documentElement}function R(e){return"html"===x(e)?e:e.assignedSlot||e.parentNode||(L(e)?e.host:null)||z(e)}function q(e){return C(e)&&"fixed"!==I(e).position?e.offsetParent:null}function V(e){for(var t=A(e),n=q(e);n&&W(n)&&"static"===I(n).position;)n=q(n);return n&&("html"===x(n)||"body"===x(n)&&"static"===I(n).position)?t:n||function(e){var t=/firefox/i.test(M());if(/Trident/i.test(M())&&C(e)&&"fixed"===I(e).position)return null;var n=R(e);for(L(n)&&(n=n.host);C(n)&&["html","body"].indexOf(x(n))<0;){var i=I(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||t&&"filter"===i.willChange||t&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(e)||t}function Y(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function K(e,t,n){return S(e,k(t,n))}function U(e){return Object.assign({},{top:0,right:0,bottom:0,left:0},e)}function Q(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}var $={name:"arrow",enabled:!0,phase:"main",fn:function(o){var s,a=o.state,c=o.name,l=o.options,u=a.elements.arrow,f=a.modifiersData.popperOffsets,d=D(a.placement),p=Y(d),h=[i,n].indexOf(d)>=0?"height":"width";if(u&&f){var m=function(e,t){return U("number"!=typeof(e="function"==typeof e?e(Object.assign({},t.rects,{placement:t.placement})):e)?e:Q(e,r))}(l.padding,a),g=H(u),v="y"===p?e:i,b="y"===p?t:n,y=a.rects.reference[h]+a.rects.reference[p]-f[p]-a.rects.popper[h],_=f[p]-a.rects.reference[p],w=V(u),E=w?"y"===p?w.clientHeight||0:w.clientWidth||0:0,O=y/2-_/2,x=m[v],A=E-g[h]-m[b],T=E/2-g[h]/2+O,C=K(x,T,A),L=p;a.modifiersData[c]=((s={})[L]=C,s.centerOffset=C-T,s)}},effect:function(e){var t=e.state,n=e.options.element,i=void 0===n?"[data-popper-arrow]":n;null!=i&&("string"!=typeof i||(i=t.elements.popper.querySelector(i)))&&F(t.elements.popper,i)&&(t.elements.arrow=i)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function G(e){return e.split("-")[1]}var X={top:"auto",right:"auto",bottom:"auto",left:"auto"};function J(o){var r,s=o.popper,c=o.popperRect,l=o.placement,u=o.variation,f=o.offsets,d=o.position,p=o.gpuAcceleration,h=o.adaptive,m=o.roundOffsets,g=o.isFixed,v=f.x,b=void 0===v?0:v,y=f.y,_=void 0===y?0:y,w="function"==typeof m?m({x:b,y:_}):{x:b,y:_};b=w.x,_=w.y;var E=f.hasOwnProperty("x"),O=f.hasOwnProperty("y"),x=i,T=e,C=window;if(h){var L=V(s),j="clientHeight",D="clientWidth";if(L===A(s)&&"static"!==I(L=z(s)).position&&"absolute"===d&&(j="scrollHeight",D="scrollWidth"),l===e||(l===i||l===n)&&u===a)T=t,_-=(g&&L===C&&C.visualViewport?C.visualViewport.height:L[j])-c.height,_*=p?1:-1;if(l===i||(l===e||l===t)&&u===a)x=n,b-=(g&&L===C&&C.visualViewport?C.visualViewport.width:L[D])-c.width,b*=p?1:-1}var S,k=Object.assign({position:d},h&&X),M=!0===m?function(e,t){var n=e.x,i=e.y,o=t.devicePixelRatio||1;return{x:P(n*o)/o||0,y:P(i*o)/o||0}}({x:b,y:_},A(s)):{x:b,y:_};return b=M.x,_=M.y,p?Object.assign({},k,((S={})[T]=O?"0":"",S[x]=E?"0":"",S.transform=(C.devicePixelRatio||1)<=1?"translate("+b+"px, "+_+"px)":"translate3d("+b+"px, "+_+"px, 0)",S)):Object.assign({},k,((r={})[T]=O?_+"px":"",r[x]=E?b+"px":"",r.transform="",r))}var Z={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(e){var t=e.state,n=e.options,i=n.gpuAcceleration,o=void 0===i||i,r=n.adaptive,s=void 0===r||r,a=n.roundOffsets,c=void 0===a||a,l={placement:D(t.placement),variation:G(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:o,isFixed:"fixed"===t.options.strategy};null!=t.modifiersData.popperOffsets&&(t.styles.popper=Object.assign({},t.styles.popper,J(Object.assign({},l,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:s,roundOffsets:c})))),null!=t.modifiersData.arrow&&(t.styles.arrow=Object.assign({},t.styles.arrow,J(Object.assign({},l,{offsets:t.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:c})))),t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-placement":t.placement})},data:{}},ee={passive:!0};var te={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(e){var t=e.state,n=e.instance,i=e.options,o=i.scroll,r=void 0===o||o,s=i.resize,a=void 0===s||s,c=A(t.elements.popper),l=[].concat(t.scrollParents.reference,t.scrollParents.popper);return r&&l.forEach((function(e){e.addEventListener("scroll",n.update,ee)})),a&&c.addEventListener("resize",n.update,ee),function(){r&&l.forEach((function(e){e.removeEventListener("scroll",n.update,ee)})),a&&c.removeEventListener("resize",n.update,ee)}},data:{}},ne={left:"right",right:"left",bottom:"top",top:"bottom"};function ie(e){return e.replace(/left|right|bottom|top/g,(function(e){return ne[e]}))}var oe={start:"end",end:"start"};function re(e){return e.replace(/start|end/g,(function(e){return oe[e]}))}function se(e){var t=A(e);return{scrollLeft:t.pageXOffset,scrollTop:t.pageYOffset}}function ae(e){return B(z(e)).left+se(e).scrollLeft}function ce(e){var t=I(e),n=t.overflow,i=t.overflowX,o=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+i)}function le(e){return["html","body","#document"].indexOf(x(e))>=0?e.ownerDocument.body:C(e)&&ce(e)?e:le(R(e))}function ue(e,t){var n;void 0===t&&(t=[]);var i=le(e),o=i===(null==(n=e.ownerDocument)?void 0:n.body),r=A(i),s=o?[r].concat(r.visualViewport||[],ce(i)?i:[]):i,a=t.concat(s);return o?a:a.concat(ue(R(s)))}function fe(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function de(e,t,n){return t===l?fe(function(e,t){var n=A(e),i=z(e),o=n.visualViewport,r=i.clientWidth,s=i.clientHeight,a=0,c=0;if(o){r=o.width,s=o.height;var l=N();(l||!l&&"fixed"===t)&&(a=o.offsetLeft,c=o.offsetTop)}return{width:r,height:s,x:a+ae(e),y:c}}(e,n)):T(t)?function(e,t){var n=B(e,!1,"fixed"===t);return n.top=n.top+e.clientTop,n.left=n.left+e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}(t,n):fe(function(e){var t,n=z(e),i=se(e),o=null==(t=e.ownerDocument)?void 0:t.body,r=S(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=S(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),a=-i.scrollLeft+ae(e),c=-i.scrollTop;return"rtl"===I(o||n).direction&&(a+=S(n.clientWidth,o?o.clientWidth:0)-r),{width:r,height:s,x:a,y:c}}(z(e)))}function pe(e,t,n,i){var o="clippingParents"===t?function(e){var t=ue(R(e)),n=["absolute","fixed"].indexOf(I(e).position)>=0&&C(e)?V(e):e;return T(n)?t.filter((function(e){return T(e)&&F(e,n)&&"body"!==x(e)})):[]}(e):[].concat(t),r=[].concat(o,[n]),s=r[0],a=r.reduce((function(t,n){var o=de(e,n,i);return t.top=S(o.top,t.top),t.right=k(o.right,t.right),t.bottom=k(o.bottom,t.bottom),t.left=S(o.left,t.left),t}),de(e,s,i));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function he(o){var r,c=o.reference,l=o.element,u=o.placement,f=u?D(u):null,d=u?G(u):null,p=c.x+c.width/2-l.width/2,h=c.y+c.height/2-l.height/2;switch(f){case e:r={x:p,y:c.y-l.height};break;case t:r={x:p,y:c.y+c.height};break;case n:r={x:c.x+c.width,y:h};break;case i:r={x:c.x-l.width,y:h};break;default:r={x:c.x,y:c.y}}var m=f?Y(f):null;if(null!=m){var g="y"===m?"height":"width";switch(d){case s:r[m]=r[m]-(c[g]/2-l[g]/2);break;case a:r[m]=r[m]+(c[g]/2-l[g]/2)}}return r}function me(i,o){void 0===o&&(o={});var s=o,a=s.placement,d=void 0===a?i.placement:a,p=s.strategy,h=void 0===p?i.strategy:p,m=s.boundary,g=void 0===m?c:m,v=s.rootBoundary,b=void 0===v?l:v,y=s.elementContext,_=void 0===y?u:y,w=s.altBoundary,E=void 0!==w&&w,O=s.padding,x=void 0===O?0:O,A=U("number"!=typeof x?x:Q(x,r)),C=_===u?f:u,L=i.rects.popper,j=i.elements[E?C:_],D=pe(T(j)?j:j.contextElement||z(i.elements.popper),g,b,h),S=B(i.elements.reference),k=he({reference:S,element:L,strategy:"absolute",placement:d}),P=fe(Object.assign({},L,k)),M=_===u?P:S,N={top:D.top-M.top+A.top,bottom:M.bottom-D.bottom+A.bottom,left:D.left-M.left+A.left,right:M.right-D.right+A.right},H=i.modifiersData.offset;if(_===u&&H){var F=H[d];Object.keys(N).forEach((function(i){var o=[n,t].indexOf(i)>=0?1:-1,r=[e,t].indexOf(i)>=0?"y":"x";N[i]+=F[r]*o}))}return N}function ge(e,t){void 0===t&&(t={});var n=t,i=n.placement,o=n.boundary,s=n.rootBoundary,a=n.padding,c=n.flipVariations,l=n.allowedAutoPlacements,u=void 0===l?p:l,f=G(i),h=f?c?d:d.filter((function(e){return G(e)===f})):r,m=h.filter((function(e){return u.indexOf(e)>=0}));0===m.length&&(m=h);var g=m.reduce((function(t,n){return t[n]=me(e,{placement:n,boundary:o,rootBoundary:s,padding:a})[D(n)],t}),{});return Object.keys(g).sort((function(e,t){return g[e]-g[t]}))}var ve={name:"flip",enabled:!0,phase:"main",fn:function(r){var a=r.state,c=r.options,l=r.name;if(!a.modifiersData[l]._skip){for(var u=c.mainAxis,f=void 0===u||u,d=c.altAxis,p=void 0===d||d,h=c.fallbackPlacements,m=c.padding,g=c.boundary,v=c.rootBoundary,b=c.altBoundary,y=c.flipVariations,_=void 0===y||y,w=c.allowedAutoPlacements,E=a.options.placement,O=D(E),x=h||(O===E||!_?[ie(E)]:function(e){if(D(e)===o)return[];var t=ie(e);return[re(e),t,re(t)]}(E)),A=[E].concat(x).reduce((function(e,t){return e.concat(D(t)===o?ge(a,{placement:t,boundary:g,rootBoundary:v,padding:m,flipVariations:_,allowedAutoPlacements:w}):t)}),[]),T=a.rects.reference,C=a.rects.popper,L=new Map,j=!0,S=A[0],k=0;k=0,H=B?"width":"height",F=me(a,{placement:P,boundary:g,rootBoundary:v,altBoundary:b,padding:m}),I=B?N?n:i:N?t:e;T[H]>C[H]&&(I=ie(I));var W=ie(I),z=[];if(f&&z.push(F[M]<=0),p&&z.push(F[I]<=0,F[W]<=0),z.every((function(e){return e}))){S=P,j=!1;break}L.set(P,z)}if(j)for(var R=function(e){var t=A.find((function(t){var n=L.get(t);if(n)return n.slice(0,e).every((function(e){return e}))}));if(t)return S=t,"break"},q=_?3:1;q>0;q--){if("break"===R(q))break}a.placement!==S&&(a.modifiersData[l]._skip=!0,a.placement=S,a.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function be(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function ye(o){return[e,n,t,i].some((function(e){return o[e]>=0}))}var _e={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(e){var t=e.state,n=e.name,i=t.rects.reference,o=t.rects.popper,r=t.modifiersData.preventOverflow,s=me(t,{elementContext:"reference"}),a=me(t,{altBoundary:!0}),c=be(s,i),l=be(a,o,r),u=ye(c),f=ye(l);t.modifiersData[n]={referenceClippingOffsets:c,popperEscapeOffsets:l,isReferenceHidden:u,hasPopperEscaped:f},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":u,"data-popper-escaped":f})}};var we={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var o=t.state,r=t.options,s=t.name,a=r.offset,c=void 0===a?[0,0]:a,l=p.reduce((function(t,r){return t[r]=function(t,o,r){var s=D(t),a=[i,e].indexOf(s)>=0?-1:1,c="function"==typeof r?r(Object.assign({},o,{placement:t})):r,l=c[0],u=c[1];return l=l||0,u=(u||0)*a,[i,n].indexOf(s)>=0?{x:u,y:l}:{x:l,y:u}}(r,o.rects,c),t}),{}),u=l[o.placement],f=u.x,d=u.y;null!=o.modifiersData.popperOffsets&&(o.modifiersData.popperOffsets.x+=f,o.modifiersData.popperOffsets.y+=d),o.modifiersData[s]=l}};var Ee={name:"popperOffsets",enabled:!0,phase:"read",fn:function(e){var t=e.state,n=e.name;t.modifiersData[n]=he({reference:t.rects.reference,element:t.rects.popper,strategy:"absolute",placement:t.placement})},data:{}};var Oe={name:"preventOverflow",enabled:!0,phase:"main",fn:function(o){var r=o.state,a=o.options,c=o.name,l=a.mainAxis,u=void 0===l||l,f=a.altAxis,d=void 0!==f&&f,p=a.boundary,h=a.rootBoundary,m=a.altBoundary,g=a.padding,v=a.tether,b=void 0===v||v,y=a.tetherOffset,_=void 0===y?0:y,w=me(r,{boundary:p,rootBoundary:h,padding:g,altBoundary:m}),E=D(r.placement),O=G(r.placement),x=!O,A=Y(E),T="x"===A?"y":"x",C=r.modifiersData.popperOffsets,L=r.rects.reference,j=r.rects.popper,P="function"==typeof _?_(Object.assign({},r.rects,{placement:r.placement})):_,M="number"==typeof P?{mainAxis:P,altAxis:P}:Object.assign({mainAxis:0,altAxis:0},P),N=r.modifiersData.offset?r.modifiersData.offset[r.placement]:null,B={x:0,y:0};if(C){if(u){var F,I="y"===A?e:i,W="y"===A?t:n,z="y"===A?"height":"width",R=C[A],q=R+w[I],U=R-w[W],Q=b?-j[z]/2:0,$=O===s?L[z]:j[z],X=O===s?-j[z]:-L[z],J=r.elements.arrow,Z=b&&J?H(J):{width:0,height:0},ee=r.modifiersData["arrow#persistent"]?r.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},te=ee[I],ne=ee[W],ie=K(0,L[z],Z[z]),oe=x?L[z]/2-Q-ie-te-M.mainAxis:$-ie-te-M.mainAxis,re=x?-L[z]/2+Q+ie+ne+M.mainAxis:X+ie+ne+M.mainAxis,se=r.elements.arrow&&V(r.elements.arrow),ae=se?"y"===A?se.clientTop||0:se.clientLeft||0:0,ce=null!=(F=null==N?void 0:N[A])?F:0,le=R+re-ce,ue=K(b?k(q,R+oe-ce-ae):q,R,b?S(U,le):U);C[A]=ue,B[A]=ue-R}if(d){var fe,de="x"===A?e:i,pe="x"===A?t:n,he=C[T],ge="y"===T?"height":"width",ve=he+w[de],be=he-w[pe],ye=-1!==[e,i].indexOf(E),_e=null!=(fe=null==N?void 0:N[T])?fe:0,we=ye?ve:he-L[ge]-j[ge]-_e+M.altAxis,Ee=ye?he+L[ge]+j[ge]-_e-M.altAxis:be,Oe=b&&ye?function(e,t,n){var i=K(e,t,n);return i>n?n:i}(we,he,Ee):K(b?we:ve,he,b?Ee:be);C[T]=Oe,B[T]=Oe-he}r.modifiersData[c]=B}},requiresIfExists:["offset"]};function xe(e,t,n){void 0===n&&(n=!1);var i,o,r=C(t),s=C(t)&&function(e){var t=e.getBoundingClientRect(),n=P(t.width)/e.offsetWidth||1,i=P(t.height)/e.offsetHeight||1;return 1!==n||1!==i}(t),a=z(t),c=B(e,s,n),l={scrollLeft:0,scrollTop:0},u={x:0,y:0};return(r||!r&&!n)&&(("body"!==x(t)||ce(a))&&(l=(i=t)!==A(i)&&C(i)?{scrollLeft:(o=i).scrollLeft,scrollTop:o.scrollTop}:se(i)),C(t)?((u=B(t,!0)).x+=t.clientLeft,u.y+=t.clientTop):a&&(u.x=ae(a))),{x:c.left+l.scrollLeft-u.x,y:c.top+l.scrollTop-u.y,width:c.width,height:c.height}}function Ae(e){var t=new Map,n=new Set,i=[];function o(e){n.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach((function(e){if(!n.has(e)){var i=t.get(e);i&&o(i)}})),i.push(e)}return e.forEach((function(e){t.set(e.name,e)})),e.forEach((function(e){n.has(e.name)||o(e)})),i}var Te={placement:"bottom",modifiers:[],strategy:"absolute"};function Ce(){for(var e=arguments.length,t=new Array(e),n=0;nPe.has(e)&&Pe.get(e).get(t)||null,remove(e,t){if(!Pe.has(e))return;const n=Pe.get(e);n.delete(t),0===n.size&&Pe.delete(e)}};const Ne="transitionend",Be=e=>(e&&window.CSS&&window.CSS.escape&&(e=e.replace(/#([^\s"#']+)/g,((e,t)=>"#".concat(CSS.escape(t))))),e),He=e=>!(!e||"object"!=typeof e)&&(void 0!==e.jquery&&(e=e[0]),void 0!==e.nodeType),Fe=e=>He(e)?e.jquery?e[0]:e:"string"==typeof e&&e.length>0?document.querySelector(Be(e)):null,Ie=e=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof e.getRootNode){const t=e.getRootNode();return t instanceof ShadowRoot?t:null}return e instanceof ShadowRoot?e:e.parentNode?Ie(e.parentNode):null},We=()=>{},ze=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,Re=[],qe=()=>"rtl"===document.documentElement.dir,Ve=function(e){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e;return"function"==typeof e?e(...arguments.length>1&&void 0!==arguments[1]?arguments[1]:[]):t},Ye=function(e,t){if(!(!(arguments.length>2&&void 0!==arguments[2])||arguments[2]))return void Ve(e);const n=(e=>{if(!e)return 0;let{transitionDuration:t,transitionDelay:n}=window.getComputedStyle(e);const i=Number.parseFloat(t),o=Number.parseFloat(n);return i||o?(t=t.split(",")[0],n=n.split(",")[0],1e3*(Number.parseFloat(t)+Number.parseFloat(n))):0})(t)+5;let i=!1;const o=n=>{let{target:r}=n;r===t&&(i=!0,t.removeEventListener(Ne,o),Ve(e))};t.addEventListener(Ne,o),setTimeout((()=>{i||t.dispatchEvent(new Event(Ne))}),n)},Ke=/[^.]*(?=\..*)\.|.*/,Ue=/\..*/,Qe=/::\d+$/,$e={};let Ge=1;const Xe={mouseenter:"mouseover",mouseleave:"mouseout"},Je=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function Ze(e,t){return t&&"".concat(t,"::").concat(Ge++)||e.uidEvent||Ge++}function et(e){const t=Ze(e);return e.uidEvent=t,$e[t]=$e[t]||{},$e[t]}function tt(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;return Object.values(e).find((e=>e.callable===t&&e.delegationSelector===n))}function nt(e,t,n){const i="string"==typeof t,o=i?n:t||n;let r=st(e);return Je.has(r)||(r=e),[i,o,r]}function it(e,t,n,i,o){if("string"!=typeof t||!e)return;let[r,s,a]=nt(t,n,i);if(t in Xe){const e=e=>function(t){if(!t.relatedTarget||t.relatedTarget!==t.delegateTarget&&!t.delegateTarget.contains(t.relatedTarget))return e.call(this,t)};s=e(s)}const c=et(e),l=c[a]||(c[a]={}),u=tt(l,s,r?n:null);if(u)return void(u.oneOff=u.oneOff&&o);const f=Ze(s,t.replace(Ke,"")),d=r?function(e,t,n){return function i(o){const r=e.querySelectorAll(t);for(let{target:s}=o;s&&s!==this;s=s.parentNode)for(const a of r)if(a===s)return ct(o,{delegateTarget:s}),i.oneOff&&at.off(e,o.type,t,n),n.apply(s,[o])}}(e,n,s):function(e,t){return function n(i){return ct(i,{delegateTarget:e}),n.oneOff&&at.off(e,i.type,t),t.apply(e,[i])}}(e,s);d.delegationSelector=r?n:null,d.callable=s,d.oneOff=o,d.uidEvent=f,l[f]=d,e.addEventListener(a,d,r)}function ot(e,t,n,i,o){const r=tt(t[n],i,o);r&&(e.removeEventListener(n,r,Boolean(o)),delete t[n][r.uidEvent])}function rt(e,t,n,i){const o=t[n]||{};for(const[r,s]of Object.entries(o))r.includes(i)&&ot(e,t,n,s.callable,s.delegationSelector)}function st(e){return e=e.replace(Ue,""),Xe[e]||e}const at={on(e,t,n,i){it(e,t,n,i,!1)},one(e,t,n,i){it(e,t,n,i,!0)},off(e,t,n,i){if("string"!=typeof t||!e)return;const[o,r,s]=nt(t,n,i),a=s!==t,c=et(e),l=c[s]||{},u=t.startsWith(".");if(void 0===r){if(u)for(const n of Object.keys(c))rt(e,c,n,t.slice(1));for(const[n,i]of Object.entries(l)){const o=n.replace(Qe,"");a&&!t.includes(o)||ot(e,c,s,i.callable,i.delegationSelector)}}else{if(!Object.keys(l).length)return;ot(e,c,s,r,o?n:null)}},trigger(e,t,n){if("string"!=typeof t||!e)return null;const i=ze();let o=null,r=!0,s=!0,a=!1;t!==st(t)&&i&&(o=i.Event(t,n),i(e).trigger(o),r=!o.isPropagationStopped(),s=!o.isImmediatePropagationStopped(),a=o.isDefaultPrevented());const c=ct(new Event(t,{bubbles:r,cancelable:!0}),n);return a&&c.preventDefault(),s&&e.dispatchEvent(c),c.defaultPrevented&&o&&o.preventDefault(),c}};function ct(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(const[n,i]of Object.entries(t))try{e[n]=i}catch{Object.defineProperty(e,n,{configurable:!0,get:()=>i})}return e}function lt(e){if("true"===e)return!0;if("false"===e)return!1;if(e===Number(e).toString())return Number(e);if(""===e||"null"===e)return null;if("string"!=typeof e)return e;try{return JSON.parse(decodeURIComponent(e))}catch{return e}}function ut(e){return e.replace(/[A-Z]/g,(e=>"-".concat(e.toLowerCase())))}const ft={setDataAttribute(e,t,n){e.setAttribute("data-bs-".concat(ut(t)),n)},removeDataAttribute(e,t){e.removeAttribute("data-bs-".concat(ut(t)))},getDataAttributes(e){if(!e)return{};const t={},n=Object.keys(e.dataset).filter((e=>e.startsWith("bs")&&!e.startsWith("bsConfig")));for(const i of n){let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),t[n]=lt(e.dataset[i])}return t},getDataAttribute:(e,t)=>lt(e.getAttribute("data-bs-".concat(ut(t))))};class dt{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(e){return e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e}_mergeConfigObj(e,t){const n=He(t)?ft.getDataAttribute(t,"config"):{};return{...this.constructor.Default,..."object"==typeof n?n:{},...He(t)?ft.getDataAttributes(t):{},..."object"==typeof e?e:{}}}_typeCheckConfig(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.constructor.DefaultType;for(const[i,o]of Object.entries(t)){const t=e[i],r=He(t)?"element":null==(n=t)?"".concat(n):Object.prototype.toString.call(n).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(o).test(r))throw new TypeError("".concat(this.constructor.NAME.toUpperCase(),': Option "').concat(i,'" provided type "').concat(r,'" but expected type "').concat(o,'".'))}var n}}class pt extends dt{constructor(e,t){super(),(e=Fe(e))&&(this._element=e,this._config=this._getConfig(t),Me.set(this._element,this.constructor.DATA_KEY,this))}dispose(){Me.remove(this._element,this.constructor.DATA_KEY),at.off(this._element,this.constructor.EVENT_KEY);for(const e of Object.getOwnPropertyNames(this))this[e]=null}_queueCallback(e,t){Ye(e,t,!(arguments.length>2&&void 0!==arguments[2])||arguments[2])}_getConfig(e){return e=this._mergeConfigObj(e,this._element),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}static getInstance(e){return Me.get(Fe(e),this.DATA_KEY)}static getOrCreateInstance(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return this.getInstance(e)||new this(e,"object"==typeof t?t:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return"bs.".concat(this.NAME)}static get EVENT_KEY(){return".".concat(this.DATA_KEY)}static eventName(e){return"".concat(e).concat(this.EVENT_KEY)}}const ht={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},mt=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),gt=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,vt=(e,t)=>{const n=e.nodeName.toLowerCase();return t.includes(n)?!mt.has(n)||Boolean(gt.test(e.nodeValue)):t.filter((e=>e instanceof RegExp)).some((e=>e.test(n)))};const bt=e=>{let t=e.getAttribute("data-bs-target");if(!t||"#"===t){let n=e.getAttribute("href");if(!n||!n.includes("#")&&!n.startsWith("."))return null;n.includes("#")&&!n.startsWith("#")&&(n="#".concat(n.split("#")[1])),t=n&&"#"!==n?n.trim():null}return t?t.split(",").map((e=>Be(e))).join(","):null},yt={find(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return[].concat(...Element.prototype.querySelectorAll.call(t,e))},findOne(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return Element.prototype.querySelector.call(t,e)},children:(e,t)=>[].concat(...e.children).filter((e=>e.matches(t))),parents(e,t){const n=[];let i=e.parentNode.closest(t);for(;i;)n.push(i),i=i.parentNode.closest(t);return n},prev(e,t){let n=e.previousElementSibling;for(;n;){if(n.matches(t))return[n];n=n.previousElementSibling}return[]},next(e,t){let n=e.nextElementSibling;for(;n;){if(n.matches(t))return[n];n=n.nextElementSibling}return[]},focusableChildren(e){const t=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((e=>"".concat(e,':not([tabindex^="-"])'))).join(",");return this.find(t,e).filter((e=>!(e=>!e||e.nodeType!==Node.ELEMENT_NODE||!!e.classList.contains("disabled")||(void 0!==e.disabled?e.disabled:e.hasAttribute("disabled")&&"false"!==e.getAttribute("disabled")))(e)&&(e=>{if(!He(e)||0===e.getClientRects().length)return!1;const t="visible"===getComputedStyle(e).getPropertyValue("visibility"),n=e.closest("details:not([open])");if(!n)return t;if(n!==e){const t=e.closest("summary");if(t&&t.parentNode!==n)return!1;if(null===t)return!1}return t})(e)))},getSelectorFromElement(e){const t=bt(e);return t&&yt.findOne(t)?t:null},getElementFromSelector(e){const t=bt(e);return t?yt.findOne(t):null},getMultipleElementsFromSelector(e){const t=bt(e);return t?yt.find(t):[]}},_t={allowList:ht,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},wt={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Et={entry:"(string|element|function|null)",selector:"(string|element)"};class Ot extends dt{constructor(e){super(),this._config=this._getConfig(e)}static get Default(){return _t}static get DefaultType(){return wt}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((e=>this._resolvePossibleFunction(e))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(e){return this._checkContent(e),this._config.content={...this._config.content,...e},this}toHtml(){const e=document.createElement("div");e.innerHTML=this._maybeSanitize(this._config.template);for(const[t,n]of Object.entries(this._config.content))this._setContent(e,n,t);const t=e.children[0],n=this._resolvePossibleFunction(this._config.extraClass);return n&&t.classList.add(...n.split(" ")),t}_typeCheckConfig(e){super._typeCheckConfig(e),this._checkContent(e.content)}_checkContent(e){for(const[t,n]of Object.entries(e))super._typeCheckConfig({selector:t,entry:n},Et)}_setContent(e,t,n){const i=yt.findOne(n,e);i&&((t=this._resolvePossibleFunction(t))?He(t)?this._putElementInTemplate(Fe(t),i):this._config.html?i.innerHTML=this._maybeSanitize(t):i.textContent=t:i.remove())}_maybeSanitize(e){return this._config.sanitize?function(e,t,n){if(!e.length)return e;if(n&&"function"==typeof n)return n(e);const i=(new window.DOMParser).parseFromString(e,"text/html"),o=[].concat(...i.body.querySelectorAll("*"));for(const e of o){const n=e.nodeName.toLowerCase();if(!Object.keys(t).includes(n)){e.remove();continue}const i=[].concat(...e.attributes),o=[].concat(t["*"]||[],t[n]||[]);for(const t of i)vt(t,o)||e.removeAttribute(t.nodeName)}return i.body.innerHTML}(e,this._config.allowList,this._config.sanitizeFn):e}_resolvePossibleFunction(e){return Ve(e,[this])}_putElementInTemplate(e,t){if(this._config.html)return t.innerHTML="",void t.append(e);t.textContent=e.textContent}}const xt=new Set(["sanitize","allowList","sanitizeFn"]),At="fade",Tt="show",Ct=".".concat("modal"),Lt="hide.bs.modal",jt="hover",Dt="focus",St={AUTO:"auto",TOP:"top",RIGHT:qe()?"left":"right",BOTTOM:"bottom",LEFT:qe()?"right":"left"},kt={allowList:ht,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Pt={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Mt extends pt{constructor(e,t){if(void 0===ke)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(e,t),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return kt}static get DefaultType(){return Pt}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),at.off(this._element.closest(Ct),Lt,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const e=at.trigger(this._element,this.constructor.eventName("show")),t=(Ie(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(e.defaultPrevented||!t)return;this._disposePopper();const n=this._getTipElement();this._element.setAttribute("aria-describedby",n.getAttribute("id"));const{container:i}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(i.append(n),at.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(n),n.classList.add(Tt),"ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))at.on(e,"mouseover",We);this._queueCallback((()=>{at.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(!this._isShown())return;if(at.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented)return;if(this._getTipElement().classList.remove(Tt),"ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))at.off(e,"mouseover",We);this._activeTrigger.click=!1,this._activeTrigger[Dt]=!1,this._activeTrigger[jt]=!1,this._isHovered=null;this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),at.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(e){const t=this._getTemplateFactory(e).toHtml();if(!t)return null;t.classList.remove(At,Tt),t.classList.add("bs-".concat(this.constructor.NAME,"-auto"));const n=(e=>{do{e+=Math.floor(1e6*Math.random())}while(document.getElementById(e));return e})(this.constructor.NAME).toString();return t.setAttribute("id",n),this._isAnimated()&&t.classList.add(At),t}setContent(e){this._newContent=e,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(e){return this._templateFactory?this._templateFactory.changeContent(e):this._templateFactory=new Ot({...this._config,content:e,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(e){return this.constructor.getOrCreateInstance(e.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(At)}_isShown(){return this.tip&&this.tip.classList.contains(Tt)}_createPopper(e){const t=Ve(this._config.placement,[this,e,this._element]),n=St[t.toUpperCase()];return Se(this._element,e,this._getPopperConfig(n))}_getOffset(){const{offset:e}=this._config;return"string"==typeof e?e.split(",").map((e=>Number.parseInt(e,10))):"function"==typeof e?t=>e(t,this._element):e}_resolvePossibleFunction(e){return Ve(e,[this._element])}_getPopperConfig(e){const t={placement:e,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:".".concat(this.constructor.NAME,"-arrow")}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:e=>{this._getTipElement().setAttribute("data-popper-placement",e.state.placement)}}]};return{...t,...Ve(this._config.popperConfig,[t])}}_setListeners(){const e=this._config.trigger.split(" ");for(const t of e)if("click"===t)at.on(this._element,this.constructor.eventName("click"),this._config.selector,(e=>{this._initializeOnDelegatedTarget(e).toggle()}));else if("manual"!==t){const e=t===jt?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),n=t===jt?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");at.on(this._element,e,this._config.selector,(e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusin"===e.type?Dt:jt]=!0,t._enter()})),at.on(this._element,n,this._config.selector,(e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusout"===e.type?Dt:jt]=t._element.contains(e.relatedTarget),t._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},at.on(this._element.closest(Ct),Lt,this._hideModalHandler)}_fixTitle(){const e=this._element.getAttribute("title");e&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",e),this._element.setAttribute("data-bs-original-title",e),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(e,t){clearTimeout(this._timeout),this._timeout=setTimeout(e,t)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(e){const t=ft.getDataAttributes(this._element);for(const e of Object.keys(t))xt.has(e)&&delete t[e];return e={...t,..."object"==typeof e&&e?e:{}},e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e.container=!1===e.container?document.body:Fe(e.container),"number"==typeof e.delay&&(e.delay={show:e.delay,hide:e.delay}),"number"==typeof e.title&&(e.title=e.title.toString()),"number"==typeof e.content&&(e.content=e.content.toString()),e}_getDelegateConfig(){const e={};for(const[t,n]of Object.entries(this._config))this.constructor.Default[t]!==n&&(e[t]=n);return e.selector=!1,e.trigger="manual",e}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(e){return this.each((function(){const t=Mt.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError('No method named "'.concat(e,'"'));t[e]()}}))}}var Nt,Bt;Nt=Mt,Bt=()=>{const e=ze();if(e){const t=Nt.NAME,n=e.fn[t];e.fn[t]=Nt.jQueryInterface,e.fn[t].Constructor=Nt,e.fn[t].noConflict=()=>(e.fn[t]=n,Nt.jQueryInterface)}},"loading"===document.readyState?(Re.length||document.addEventListener("DOMContentLoaded",(()=>{for(const e of Re)e()})),Re.push(Bt)):Bt();const Ht=document.getElementById("mode-toggle");function Ft(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var i=n.call(e,t||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function It(e,t,n){return(t=Ft(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}const Wt="sidebar-display";class zt{static toggle(){!1===zt.isExpanded?document.body.setAttribute(Wt,""):document.body.removeAttribute(Wt),zt.isExpanded=!zt.isExpanded}}It(zt,"isExpanded",!1);const Rt=document.getElementById("sidebar-trigger"),qt=document.getElementById("search-trigger"),Vt=document.getElementById("search-cancel"),Yt=document.querySelectorAll("#main-wrapper>.container>.row"),Kt=document.getElementById("topbar-title"),Ut=document.getElementById("search"),Qt=document.getElementById("search-result-wrapper"),$t=document.getElementById("search-results"),Gt=document.getElementById("search-input"),Xt=document.getElementById("search-hints"),Jt="d-block",Zt="d-none",en="input-focus",tn="d-flex";class nn{static on(){Rt.classList.add(Zt),Kt.classList.add(Zt),qt.classList.add(Zt),Ut.classList.add(tn),Vt.classList.add(Jt)}static off(){Vt.classList.remove(Jt),Ut.classList.remove(tn),Rt.classList.remove(Zt),Kt.classList.remove(Zt),qt.classList.remove(Zt)}}class on{static on(){this.resultVisible||(Qt.classList.remove(Zt),Yt.forEach((e=>{e.classList.add(Zt)})),this.resultVisible=!0)}static off(){this.resultVisible&&($t.innerHTML="",Xt.classList.contains(Zt)&&Xt.classList.remove(Zt),Qt.classList.add(Zt),Yt.forEach((e=>{e.classList.remove(Zt)})),Gt.textContent="",this.resultVisible=!1)}}function rn(){return Vt.classList.contains(Jt)}It(on,"resultVisible",!1),Ht&&Ht.addEventListener("click",(()=>{modeToggle.flipMode()})),document.getElementById("sidebar-trigger").addEventListener("click",zt.toggle),document.getElementById("mask").addEventListener("click",zt.toggle),qt.addEventListener("click",(()=>{nn.on(),on.on(),Gt.focus()})),Vt.addEventListener("click",(()=>{nn.off(),on.off()})),Gt.addEventListener("focus",(()=>{Ut.classList.add(en)})),Gt.addEventListener("focusout",(()=>{Ut.classList.remove(en)})),Gt.addEventListener("input",(()=>{""===Gt.value?rn()?Xt.classList.remove(Zt):on.off():(on.on(),rn()&&Xt.classList.add(Zt))})),function(){const e=document.getElementById("back-to-top");window.addEventListener("scroll",(()=>{window.scrollY>50?e.classList.add("show"):e.classList.remove("show")})),e.addEventListener("click",(()=>{window.scrollTo({top:0})}))}(),[...document.querySelectorAll('[data-bs-toggle="tooltip"]')].map((e=>new Mt(e)))}(); diff --git a/assets/js/dist/home.min.js b/assets/js/dist/home.min.js new file mode 100644 index 0000000..63444a4 --- /dev/null +++ b/assets/js/dist/home.min.js @@ -0,0 +1,4 @@ +/*! + * jekyll-theme-chirpy v7.1.1 | © 2019 Cotes Chung | MIT Licensed | https://github.com/cotes2020/jekyll-theme-chirpy/ + */ +!function(){"use strict";var t="top",e="bottom",n="right",i="left",o="auto",r=[t,e,n,i],s="start",a="end",c="clippingParents",l="viewport",u="popper",f="reference",d=r.reduce((function(t,e){return t.concat([e+"-"+s,e+"-"+a])}),[]),p=[].concat(r,[o]).reduce((function(t,e){return t.concat([e,e+"-"+s,e+"-"+a])}),[]),h="beforeRead",m="read",g="afterRead",v="beforeMain",b="main",y="afterMain",_="beforeWrite",w="write",E="afterWrite",x=[h,m,g,v,b,y,_,w,E];function O(t){return t?(t.nodeName||"").toLowerCase():null}function A(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function C(t){return t instanceof A(t).Element||t instanceof Element}function T(t){return t instanceof A(t).HTMLElement||t instanceof HTMLElement}function L(t){return"undefined"!=typeof ShadowRoot&&(t instanceof A(t).ShadowRoot||t instanceof ShadowRoot)}var j={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var n=e.styles[t]||{},i=e.attributes[t]||{},o=e.elements[t];T(o)&&O(o)&&(Object.assign(o.style,n),Object.keys(i).forEach((function(t){var e=i[t];!1===e?o.removeAttribute(t):o.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,n={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,n.popper),e.styles=n,e.elements.arrow&&Object.assign(e.elements.arrow.style,n.arrow),function(){Object.keys(e.elements).forEach((function(t){var i=e.elements[t],o=e.attributes[t]||{},r=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:n[t]).reduce((function(t,e){return t[e]="",t}),{});T(i)&&O(i)&&(Object.assign(i.style,r),Object.keys(o).forEach((function(t){i.removeAttribute(t)})))}))}},requires:["computeStyles"]};function S(t){return t.split("-")[0]}var D=Math.max,k=Math.min,M=Math.round;function P(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function N(){return!/^((?!chrome|android).)*safari/i.test(P())}function F(t,e,n){void 0===e&&(e=!1),void 0===n&&(n=!1);var i=t.getBoundingClientRect(),o=1,r=1;e&&T(t)&&(o=t.offsetWidth>0&&M(i.width)/t.offsetWidth||1,r=t.offsetHeight>0&&M(i.height)/t.offsetHeight||1);var s=(C(t)?A(t):window).visualViewport,a=!N()&&n,c=(i.left+(a&&s?s.offsetLeft:0))/o,l=(i.top+(a&&s?s.offsetTop:0))/r,u=i.width/o,f=i.height/r;return{width:u,height:f,top:l,right:c+u,bottom:l+f,left:c,x:c,y:l}}function B(t){var e=F(t),n=t.offsetWidth,i=t.offsetHeight;return Math.abs(e.width-n)<=1&&(n=e.width),Math.abs(e.height-i)<=1&&(i=e.height),{x:t.offsetLeft,y:t.offsetTop,width:n,height:i}}function H(t,e){var n=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(n&&L(n)){var i=e;do{if(i&&t.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function I(t){return A(t).getComputedStyle(t)}function R(t){return["table","td","th"].indexOf(O(t))>=0}function z(t){return((C(t)?t.ownerDocument:t.document)||window.document).documentElement}function W(t){return"html"===O(t)?t:t.assignedSlot||t.parentNode||(L(t)?t.host:null)||z(t)}function q(t){return T(t)&&"fixed"!==I(t).position?t.offsetParent:null}function V(t){for(var e=A(t),n=q(t);n&&R(n)&&"static"===I(n).position;)n=q(n);return n&&("html"===O(n)||"body"===O(n)&&"static"===I(n).position)?e:n||function(t){var e=/firefox/i.test(P());if(/Trident/i.test(P())&&T(t)&&"fixed"===I(t).position)return null;var n=W(t);for(L(n)&&(n=n.host);T(n)&&["html","body"].indexOf(O(n))<0;){var i=I(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||e&&"filter"===i.willChange||e&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(t)||e}function U(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Y(t,e,n){return D(t,k(e,n))}function K(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function Q(t,e){return e.reduce((function(e,n){return e[n]=t,e}),{})}var $={name:"arrow",enabled:!0,phase:"main",fn:function(o){var s,a=o.state,c=o.name,l=o.options,u=a.elements.arrow,f=a.modifiersData.popperOffsets,d=S(a.placement),p=U(d),h=[i,n].indexOf(d)>=0?"height":"width";if(u&&f){var m=function(t,e){return K("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:Q(t,r))}(l.padding,a),g=B(u),v="y"===p?t:i,b="y"===p?e:n,y=a.rects.reference[h]+a.rects.reference[p]-f[p]-a.rects.popper[h],_=f[p]-a.rects.reference[p],w=V(u),E=w?"y"===p?w.clientHeight||0:w.clientWidth||0:0,x=y/2-_/2,O=m[v],A=E-g[h]-m[b],C=E/2-g[h]/2+x,T=Y(O,C,A),L=p;a.modifiersData[c]=((s={})[L]=T,s.centerOffset=T-C,s)}},effect:function(t){var e=t.state,n=t.options.element,i=void 0===n?"[data-popper-arrow]":n;null!=i&&("string"!=typeof i||(i=e.elements.popper.querySelector(i)))&&H(e.elements.popper,i)&&(e.elements.arrow=i)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function G(t){return t.split("-")[1]}var X={top:"auto",right:"auto",bottom:"auto",left:"auto"};function J(o){var r,s=o.popper,c=o.popperRect,l=o.placement,u=o.variation,f=o.offsets,d=o.position,p=o.gpuAcceleration,h=o.adaptive,m=o.roundOffsets,g=o.isFixed,v=f.x,b=void 0===v?0:v,y=f.y,_=void 0===y?0:y,w="function"==typeof m?m({x:b,y:_}):{x:b,y:_};b=w.x,_=w.y;var E=f.hasOwnProperty("x"),x=f.hasOwnProperty("y"),O=i,C=t,T=window;if(h){var L=V(s),j="clientHeight",S="clientWidth";if(L===A(s)&&"static"!==I(L=z(s)).position&&"absolute"===d&&(j="scrollHeight",S="scrollWidth"),l===t||(l===i||l===n)&&u===a)C=e,_-=(g&&L===T&&T.visualViewport?T.visualViewport.height:L[j])-c.height,_*=p?1:-1;if(l===i||(l===t||l===e)&&u===a)O=n,b-=(g&&L===T&&T.visualViewport?T.visualViewport.width:L[S])-c.width,b*=p?1:-1}var D,k=Object.assign({position:d},h&&X),P=!0===m?function(t,e){var n=t.x,i=t.y,o=e.devicePixelRatio||1;return{x:M(n*o)/o||0,y:M(i*o)/o||0}}({x:b,y:_},A(s)):{x:b,y:_};return b=P.x,_=P.y,p?Object.assign({},k,((D={})[C]=x?"0":"",D[O]=E?"0":"",D.transform=(T.devicePixelRatio||1)<=1?"translate("+b+"px, "+_+"px)":"translate3d("+b+"px, "+_+"px, 0)",D)):Object.assign({},k,((r={})[C]=x?_+"px":"",r[O]=E?b+"px":"",r.transform="",r))}var Z={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,n=t.options,i=n.gpuAcceleration,o=void 0===i||i,r=n.adaptive,s=void 0===r||r,a=n.roundOffsets,c=void 0===a||a,l={placement:S(e.placement),variation:G(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:o,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,J(Object.assign({},l,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:s,roundOffsets:c})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,J(Object.assign({},l,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:c})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}},tt={passive:!0};var et={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,n=t.instance,i=t.options,o=i.scroll,r=void 0===o||o,s=i.resize,a=void 0===s||s,c=A(e.elements.popper),l=[].concat(e.scrollParents.reference,e.scrollParents.popper);return r&&l.forEach((function(t){t.addEventListener("scroll",n.update,tt)})),a&&c.addEventListener("resize",n.update,tt),function(){r&&l.forEach((function(t){t.removeEventListener("scroll",n.update,tt)})),a&&c.removeEventListener("resize",n.update,tt)}},data:{}},nt={left:"right",right:"left",bottom:"top",top:"bottom"};function it(t){return t.replace(/left|right|bottom|top/g,(function(t){return nt[t]}))}var ot={start:"end",end:"start"};function rt(t){return t.replace(/start|end/g,(function(t){return ot[t]}))}function st(t){var e=A(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function at(t){return F(z(t)).left+st(t).scrollLeft}function ct(t){var e=I(t),n=e.overflow,i=e.overflowX,o=e.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+i)}function lt(t){return["html","body","#document"].indexOf(O(t))>=0?t.ownerDocument.body:T(t)&&ct(t)?t:lt(W(t))}function ut(t,e){var n;void 0===e&&(e=[]);var i=lt(t),o=i===(null==(n=t.ownerDocument)?void 0:n.body),r=A(i),s=o?[r].concat(r.visualViewport||[],ct(i)?i:[]):i,a=e.concat(s);return o?a:a.concat(ut(W(s)))}function ft(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function dt(t,e,n){return e===l?ft(function(t,e){var n=A(t),i=z(t),o=n.visualViewport,r=i.clientWidth,s=i.clientHeight,a=0,c=0;if(o){r=o.width,s=o.height;var l=N();(l||!l&&"fixed"===e)&&(a=o.offsetLeft,c=o.offsetTop)}return{width:r,height:s,x:a+at(t),y:c}}(t,n)):C(e)?function(t,e){var n=F(t,!1,"fixed"===e);return n.top=n.top+t.clientTop,n.left=n.left+t.clientLeft,n.bottom=n.top+t.clientHeight,n.right=n.left+t.clientWidth,n.width=t.clientWidth,n.height=t.clientHeight,n.x=n.left,n.y=n.top,n}(e,n):ft(function(t){var e,n=z(t),i=st(t),o=null==(e=t.ownerDocument)?void 0:e.body,r=D(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=D(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),a=-i.scrollLeft+at(t),c=-i.scrollTop;return"rtl"===I(o||n).direction&&(a+=D(n.clientWidth,o?o.clientWidth:0)-r),{width:r,height:s,x:a,y:c}}(z(t)))}function pt(t,e,n,i){var o="clippingParents"===e?function(t){var e=ut(W(t)),n=["absolute","fixed"].indexOf(I(t).position)>=0&&T(t)?V(t):t;return C(n)?e.filter((function(t){return C(t)&&H(t,n)&&"body"!==O(t)})):[]}(t):[].concat(e),r=[].concat(o,[n]),s=r[0],a=r.reduce((function(e,n){var o=dt(t,n,i);return e.top=D(o.top,e.top),e.right=k(o.right,e.right),e.bottom=k(o.bottom,e.bottom),e.left=D(o.left,e.left),e}),dt(t,s,i));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function ht(o){var r,c=o.reference,l=o.element,u=o.placement,f=u?S(u):null,d=u?G(u):null,p=c.x+c.width/2-l.width/2,h=c.y+c.height/2-l.height/2;switch(f){case t:r={x:p,y:c.y-l.height};break;case e:r={x:p,y:c.y+c.height};break;case n:r={x:c.x+c.width,y:h};break;case i:r={x:c.x-l.width,y:h};break;default:r={x:c.x,y:c.y}}var m=f?U(f):null;if(null!=m){var g="y"===m?"height":"width";switch(d){case s:r[m]=r[m]-(c[g]/2-l[g]/2);break;case a:r[m]=r[m]+(c[g]/2-l[g]/2)}}return r}function mt(i,o){void 0===o&&(o={});var s=o,a=s.placement,d=void 0===a?i.placement:a,p=s.strategy,h=void 0===p?i.strategy:p,m=s.boundary,g=void 0===m?c:m,v=s.rootBoundary,b=void 0===v?l:v,y=s.elementContext,_=void 0===y?u:y,w=s.altBoundary,E=void 0!==w&&w,x=s.padding,O=void 0===x?0:x,A=K("number"!=typeof O?O:Q(O,r)),T=_===u?f:u,L=i.rects.popper,j=i.elements[E?T:_],S=pt(C(j)?j:j.contextElement||z(i.elements.popper),g,b,h),D=F(i.elements.reference),k=ht({reference:D,element:L,strategy:"absolute",placement:d}),M=ft(Object.assign({},L,k)),P=_===u?M:D,N={top:S.top-P.top+A.top,bottom:P.bottom-S.bottom+A.bottom,left:S.left-P.left+A.left,right:P.right-S.right+A.right},B=i.modifiersData.offset;if(_===u&&B){var H=B[d];Object.keys(N).forEach((function(i){var o=[n,e].indexOf(i)>=0?1:-1,r=[t,e].indexOf(i)>=0?"y":"x";N[i]+=H[r]*o}))}return N}function gt(t,e){void 0===e&&(e={});var n=e,i=n.placement,o=n.boundary,s=n.rootBoundary,a=n.padding,c=n.flipVariations,l=n.allowedAutoPlacements,u=void 0===l?p:l,f=G(i),h=f?c?d:d.filter((function(t){return G(t)===f})):r,m=h.filter((function(t){return u.indexOf(t)>=0}));0===m.length&&(m=h);var g=m.reduce((function(e,n){return e[n]=mt(t,{placement:n,boundary:o,rootBoundary:s,padding:a})[S(n)],e}),{});return Object.keys(g).sort((function(t,e){return g[t]-g[e]}))}var vt={name:"flip",enabled:!0,phase:"main",fn:function(r){var a=r.state,c=r.options,l=r.name;if(!a.modifiersData[l]._skip){for(var u=c.mainAxis,f=void 0===u||u,d=c.altAxis,p=void 0===d||d,h=c.fallbackPlacements,m=c.padding,g=c.boundary,v=c.rootBoundary,b=c.altBoundary,y=c.flipVariations,_=void 0===y||y,w=c.allowedAutoPlacements,E=a.options.placement,x=S(E),O=h||(x===E||!_?[it(E)]:function(t){if(S(t)===o)return[];var e=it(t);return[rt(t),e,rt(e)]}(E)),A=[E].concat(O).reduce((function(t,e){return t.concat(S(e)===o?gt(a,{placement:e,boundary:g,rootBoundary:v,padding:m,flipVariations:_,allowedAutoPlacements:w}):e)}),[]),C=a.rects.reference,T=a.rects.popper,L=new Map,j=!0,D=A[0],k=0;k=0,B=F?"width":"height",H=mt(a,{placement:M,boundary:g,rootBoundary:v,altBoundary:b,padding:m}),I=F?N?n:i:N?e:t;C[B]>T[B]&&(I=it(I));var R=it(I),z=[];if(f&&z.push(H[P]<=0),p&&z.push(H[I]<=0,H[R]<=0),z.every((function(t){return t}))){D=M,j=!1;break}L.set(M,z)}if(j)for(var W=function(t){var e=A.find((function(e){var n=L.get(e);if(n)return n.slice(0,t).every((function(t){return t}))}));if(e)return D=e,"break"},q=_?3:1;q>0;q--){if("break"===W(q))break}a.placement!==D&&(a.modifiersData[l]._skip=!0,a.placement=D,a.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function bt(t,e,n){return void 0===n&&(n={x:0,y:0}),{top:t.top-e.height-n.y,right:t.right-e.width+n.x,bottom:t.bottom-e.height+n.y,left:t.left-e.width-n.x}}function yt(o){return[t,n,e,i].some((function(t){return o[t]>=0}))}var _t={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,n=t.name,i=e.rects.reference,o=e.rects.popper,r=e.modifiersData.preventOverflow,s=mt(e,{elementContext:"reference"}),a=mt(e,{altBoundary:!0}),c=bt(s,i),l=bt(a,o,r),u=yt(c),f=yt(l);e.modifiersData[n]={referenceClippingOffsets:c,popperEscapeOffsets:l,isReferenceHidden:u,hasPopperEscaped:f},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":u,"data-popper-escaped":f})}};var wt={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(e){var o=e.state,r=e.options,s=e.name,a=r.offset,c=void 0===a?[0,0]:a,l=p.reduce((function(e,r){return e[r]=function(e,o,r){var s=S(e),a=[i,t].indexOf(s)>=0?-1:1,c="function"==typeof r?r(Object.assign({},o,{placement:e})):r,l=c[0],u=c[1];return l=l||0,u=(u||0)*a,[i,n].indexOf(s)>=0?{x:u,y:l}:{x:l,y:u}}(r,o.rects,c),e}),{}),u=l[o.placement],f=u.x,d=u.y;null!=o.modifiersData.popperOffsets&&(o.modifiersData.popperOffsets.x+=f,o.modifiersData.popperOffsets.y+=d),o.modifiersData[s]=l}};var Et={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,n=t.name;e.modifiersData[n]=ht({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}};var xt={name:"preventOverflow",enabled:!0,phase:"main",fn:function(o){var r=o.state,a=o.options,c=o.name,l=a.mainAxis,u=void 0===l||l,f=a.altAxis,d=void 0!==f&&f,p=a.boundary,h=a.rootBoundary,m=a.altBoundary,g=a.padding,v=a.tether,b=void 0===v||v,y=a.tetherOffset,_=void 0===y?0:y,w=mt(r,{boundary:p,rootBoundary:h,padding:g,altBoundary:m}),E=S(r.placement),x=G(r.placement),O=!x,A=U(E),C="x"===A?"y":"x",T=r.modifiersData.popperOffsets,L=r.rects.reference,j=r.rects.popper,M="function"==typeof _?_(Object.assign({},r.rects,{placement:r.placement})):_,P="number"==typeof M?{mainAxis:M,altAxis:M}:Object.assign({mainAxis:0,altAxis:0},M),N=r.modifiersData.offset?r.modifiersData.offset[r.placement]:null,F={x:0,y:0};if(T){if(u){var H,I="y"===A?t:i,R="y"===A?e:n,z="y"===A?"height":"width",W=T[A],q=W+w[I],K=W-w[R],Q=b?-j[z]/2:0,$=x===s?L[z]:j[z],X=x===s?-j[z]:-L[z],J=r.elements.arrow,Z=b&&J?B(J):{width:0,height:0},tt=r.modifiersData["arrow#persistent"]?r.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},et=tt[I],nt=tt[R],it=Y(0,L[z],Z[z]),ot=O?L[z]/2-Q-it-et-P.mainAxis:$-it-et-P.mainAxis,rt=O?-L[z]/2+Q+it+nt+P.mainAxis:X+it+nt+P.mainAxis,st=r.elements.arrow&&V(r.elements.arrow),at=st?"y"===A?st.clientTop||0:st.clientLeft||0:0,ct=null!=(H=null==N?void 0:N[A])?H:0,lt=W+rt-ct,ut=Y(b?k(q,W+ot-ct-at):q,W,b?D(K,lt):K);T[A]=ut,F[A]=ut-W}if(d){var ft,dt="x"===A?t:i,pt="x"===A?e:n,ht=T[C],gt="y"===C?"height":"width",vt=ht+w[dt],bt=ht-w[pt],yt=-1!==[t,i].indexOf(E),_t=null!=(ft=null==N?void 0:N[C])?ft:0,wt=yt?vt:ht-L[gt]-j[gt]-_t+P.altAxis,Et=yt?ht+L[gt]+j[gt]-_t-P.altAxis:bt,xt=b&&yt?function(t,e,n){var i=Y(t,e,n);return i>n?n:i}(wt,ht,Et):Y(b?wt:vt,ht,b?Et:bt);T[C]=xt,F[C]=xt-ht}r.modifiersData[c]=F}},requiresIfExists:["offset"]};function Ot(t,e,n){void 0===n&&(n=!1);var i,o,r=T(e),s=T(e)&&function(t){var e=t.getBoundingClientRect(),n=M(e.width)/t.offsetWidth||1,i=M(e.height)/t.offsetHeight||1;return 1!==n||1!==i}(e),a=z(e),c=F(t,s,n),l={scrollLeft:0,scrollTop:0},u={x:0,y:0};return(r||!r&&!n)&&(("body"!==O(e)||ct(a))&&(l=(i=e)!==A(i)&&T(i)?{scrollLeft:(o=i).scrollLeft,scrollTop:o.scrollTop}:st(i)),T(e)?((u=F(e,!0)).x+=e.clientLeft,u.y+=e.clientTop):a&&(u.x=at(a))),{x:c.left+l.scrollLeft-u.x,y:c.top+l.scrollTop-u.y,width:c.width,height:c.height}}function At(t){var e=new Map,n=new Set,i=[];function o(t){n.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!n.has(t)){var i=e.get(t);i&&o(i)}})),i.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){n.has(t.name)||o(t)})),i}var Ct={placement:"bottom",modifiers:[],strategy:"absolute"};function Tt(){for(var t=arguments.length,e=new Array(t),n=0;nMt.has(t)&&Mt.get(t).get(e)||null,remove(t,e){if(!Mt.has(t))return;const n=Mt.get(t);n.delete(e),0===n.size&&Mt.delete(t)}};const Nt="transitionend",Ft=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>"#".concat(CSS.escape(e))))),t),Bt=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),Ht=t=>Bt(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(Ft(t)):null,It=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?It(t.parentNode):null},Rt=()=>{},zt=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,Wt=[],qt=()=>"rtl"===document.documentElement.dir,Vt=t=>{var e;e=()=>{const e=zt();if(e){const n=t.NAME,i=e.fn[n];e.fn[n]=t.jQueryInterface,e.fn[n].Constructor=t,e.fn[n].noConflict=()=>(e.fn[n]=i,t.jQueryInterface)}},"loading"===document.readyState?(Wt.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of Wt)t()})),Wt.push(e)):e()},Ut=function(t){let e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:t;return"function"==typeof t?t(...arguments.length>1&&void 0!==arguments[1]?arguments[1]:[]):e},Yt=function(t,e){if(!(!(arguments.length>2&&void 0!==arguments[2])||arguments[2]))return void Ut(t);const n=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:n}=window.getComputedStyle(t);const i=Number.parseFloat(e),o=Number.parseFloat(n);return i||o?(e=e.split(",")[0],n=n.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(n))):0})(e)+5;let i=!1;const o=n=>{let{target:r}=n;r===e&&(i=!0,e.removeEventListener(Nt,o),Ut(t))};e.addEventListener(Nt,o),setTimeout((()=>{i||e.dispatchEvent(new Event(Nt))}),n)},Kt=/[^.]*(?=\..*)\.|.*/,Qt=/\..*/,$t=/::\d+$/,Gt={};let Xt=1;const Jt={mouseenter:"mouseover",mouseleave:"mouseout"},Zt=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function te(t,e){return e&&"".concat(e,"::").concat(Xt++)||t.uidEvent||Xt++}function ee(t){const e=te(t);return t.uidEvent=e,Gt[e]=Gt[e]||{},Gt[e]}function ne(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===n))}function ie(t,e,n){const i="string"==typeof e,o=i?n:e||n;let r=ae(t);return Zt.has(r)||(r=t),[i,o,r]}function oe(t,e,n,i,o){if("string"!=typeof e||!t)return;let[r,s,a]=ie(e,n,i);if(e in Jt){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};s=t(s)}const c=ee(t),l=c[a]||(c[a]={}),u=ne(l,s,r?n:null);if(u)return void(u.oneOff=u.oneOff&&o);const f=te(s,e.replace(Kt,"")),d=r?function(t,e,n){return function i(o){const r=t.querySelectorAll(e);for(let{target:s}=o;s&&s!==this;s=s.parentNode)for(const a of r)if(a===s)return le(o,{delegateTarget:s}),i.oneOff&&ce.off(t,o.type,e,n),n.apply(s,[o])}}(t,n,s):function(t,e){return function n(i){return le(i,{delegateTarget:t}),n.oneOff&&ce.off(t,i.type,e),e.apply(t,[i])}}(t,s);d.delegationSelector=r?n:null,d.callable=s,d.oneOff=o,d.uidEvent=f,l[f]=d,t.addEventListener(a,d,r)}function re(t,e,n,i,o){const r=ne(e[n],i,o);r&&(t.removeEventListener(n,r,Boolean(o)),delete e[n][r.uidEvent])}function se(t,e,n,i){const o=e[n]||{};for(const[r,s]of Object.entries(o))r.includes(i)&&re(t,e,n,s.callable,s.delegationSelector)}function ae(t){return t=t.replace(Qt,""),Jt[t]||t}const ce={on(t,e,n,i){oe(t,e,n,i,!1)},one(t,e,n,i){oe(t,e,n,i,!0)},off(t,e,n,i){if("string"!=typeof e||!t)return;const[o,r,s]=ie(e,n,i),a=s!==e,c=ee(t),l=c[s]||{},u=e.startsWith(".");if(void 0===r){if(u)for(const n of Object.keys(c))se(t,c,n,e.slice(1));for(const[n,i]of Object.entries(l)){const o=n.replace($t,"");a&&!e.includes(o)||re(t,c,s,i.callable,i.delegationSelector)}}else{if(!Object.keys(l).length)return;re(t,c,s,r,o?n:null)}},trigger(t,e,n){if("string"!=typeof e||!t)return null;const i=zt();let o=null,r=!0,s=!0,a=!1;e!==ae(e)&&i&&(o=i.Event(e,n),i(t).trigger(o),r=!o.isPropagationStopped(),s=!o.isImmediatePropagationStopped(),a=o.isDefaultPrevented());const c=le(new Event(e,{bubbles:r,cancelable:!0}),n);return a&&c.preventDefault(),s&&t.dispatchEvent(c),c.defaultPrevented&&o&&o.preventDefault(),c}};function le(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(const[n,i]of Object.entries(e))try{t[n]=i}catch{Object.defineProperty(t,n,{configurable:!0,get:()=>i})}return t}function ue(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch{return t}}function fe(t){return t.replace(/[A-Z]/g,(t=>"-".concat(t.toLowerCase())))}const de={setDataAttribute(t,e,n){t.setAttribute("data-bs-".concat(fe(e)),n)},removeDataAttribute(t,e){t.removeAttribute("data-bs-".concat(fe(e)))},getDataAttributes(t){if(!t)return{};const e={},n=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const i of n){let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=ue(t.dataset[i])}return e},getDataAttribute:(t,e)=>ue(t.getAttribute("data-bs-".concat(fe(e))))};class pe{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const n=Bt(e)?de.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof n?n:{},...Bt(e)?de.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.constructor.DefaultType;for(const[i,o]of Object.entries(e)){const e=t[i],r=Bt(e)?"element":null==(n=e)?"".concat(n):Object.prototype.toString.call(n).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(o).test(r))throw new TypeError("".concat(this.constructor.NAME.toUpperCase(),': Option "').concat(i,'" provided type "').concat(r,'" but expected type "').concat(o,'".'))}var n}}class he extends pe{constructor(t,e){super(),(t=Ht(t))&&(this._element=t,this._config=this._getConfig(e),Pt.set(this._element,this.constructor.DATA_KEY,this))}dispose(){Pt.remove(this._element,this.constructor.DATA_KEY),ce.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e){Yt(t,e,!(arguments.length>2&&void 0!==arguments[2])||arguments[2])}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return Pt.get(Ht(t),this.DATA_KEY)}static getOrCreateInstance(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return"bs.".concat(this.NAME)}static get EVENT_KEY(){return".".concat(this.DATA_KEY)}static eventName(t){return"".concat(t).concat(this.EVENT_KEY)}}const me={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},ge=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),ve=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,be=(t,e)=>{const n=t.nodeName.toLowerCase();return e.includes(n)?!ge.has(n)||Boolean(ve.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(n)))};const ye=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let n=t.getAttribute("href");if(!n||!n.includes("#")&&!n.startsWith("."))return null;n.includes("#")&&!n.startsWith("#")&&(n="#".concat(n.split("#")[1])),e=n&&"#"!==n?n.trim():null}return e?e.split(",").map((t=>Ft(t))).join(","):null},_e={find(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return[].concat(...Element.prototype.querySelectorAll.call(e,t))},findOne(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return Element.prototype.querySelector.call(e,t)},children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const n=[];let i=t.parentNode.closest(e);for(;i;)n.push(i),i=i.parentNode.closest(e);return n},prev(t,e){let n=t.previousElementSibling;for(;n;){if(n.matches(e))return[n];n=n.previousElementSibling}return[]},next(t,e){let n=t.nextElementSibling;for(;n;){if(n.matches(e))return[n];n=n.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>"".concat(t,':not([tabindex^="-"])'))).join(",");return this.find(e,t).filter((t=>!(t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")))(t)&&(t=>{if(!Bt(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),n=t.closest("details:not([open])");if(!n)return e;if(n!==t){const e=t.closest("summary");if(e&&e.parentNode!==n)return!1;if(null===e)return!1}return e})(t)))},getSelectorFromElement(t){const e=ye(t);return e&&_e.findOne(e)?e:null},getElementFromSelector(t){const e=ye(t);return e?_e.findOne(e):null},getMultipleElementsFromSelector(t){const e=ye(t);return e?_e.find(e):[]}},we={allowList:me,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Ee={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},xe={entry:"(string|element|function|null)",selector:"(string|element)"};class Oe extends pe{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return we}static get DefaultType(){return Ee}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,n]of Object.entries(this._config.content))this._setContent(t,n,e);const e=t.children[0],n=this._resolvePossibleFunction(this._config.extraClass);return n&&e.classList.add(...n.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,n]of Object.entries(t))super._typeCheckConfig({selector:e,entry:n},xe)}_setContent(t,e,n){const i=_e.findOne(n,t);i&&((e=this._resolvePossibleFunction(e))?Bt(e)?this._putElementInTemplate(Ht(e),i):this._config.html?i.innerHTML=this._maybeSanitize(e):i.textContent=e:i.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,n){if(!t.length)return t;if(n&&"function"==typeof n)return n(t);const i=(new window.DOMParser).parseFromString(t,"text/html"),o=[].concat(...i.body.querySelectorAll("*"));for(const t of o){const n=t.nodeName.toLowerCase();if(!Object.keys(e).includes(n)){t.remove();continue}const i=[].concat(...t.attributes),o=[].concat(e["*"]||[],e[n]||[]);for(const e of i)be(e,o)||t.removeAttribute(e.nodeName)}return i.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return Ut(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Ae=new Set(["sanitize","allowList","sanitizeFn"]),Ce="fade",Te="show",Le=".".concat("modal"),je="hide.bs.modal",Se="hover",De="focus",ke={AUTO:"auto",TOP:"top",RIGHT:qt()?"left":"right",BOTTOM:"bottom",LEFT:qt()?"right":"left"},Me={allowList:me,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Pe={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Ne extends he{constructor(t,e){if(void 0===kt)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Me}static get DefaultType(){return Pe}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),ce.off(this._element.closest(Le),je,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=ce.trigger(this._element,this.constructor.eventName("show")),e=(It(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const n=this._getTipElement();this._element.setAttribute("aria-describedby",n.getAttribute("id"));const{container:i}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(i.append(n),ce.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(n),n.classList.add(Te),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))ce.on(t,"mouseover",Rt);this._queueCallback((()=>{ce.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(!this._isShown())return;if(ce.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented)return;if(this._getTipElement().classList.remove(Te),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))ce.off(t,"mouseover",Rt);this._activeTrigger.click=!1,this._activeTrigger[De]=!1,this._activeTrigger[Se]=!1,this._isHovered=null;this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),ce.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ce,Te),e.classList.add("bs-".concat(this.constructor.NAME,"-auto"));const n=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",n),this._isAnimated()&&e.classList.add(Ce),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Oe({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ce)}_isShown(){return this.tip&&this.tip.classList.contains(Te)}_createPopper(t){const e=Ut(this._config.placement,[this,t,this._element]),n=ke[e.toUpperCase()];return Dt(this._element,t,this._getPopperConfig(n))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return Ut(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:".".concat(this.constructor.NAME,"-arrow")}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...Ut(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)ce.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===Se?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),n=e===Se?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");ce.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?De:Se]=!0,e._enter()})),ce.on(this._element,n,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?De:Se]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},ce.on(this._element.closest(Le),je,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=de.getDataAttributes(this._element);for(const t of Object.keys(e))Ae.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:Ht(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,n]of Object.entries(this._config))this.constructor.Default[e]!==n&&(t[e]=n);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=Ne.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError('No method named "'.concat(t,'"'));e[t]()}}))}}Vt(Ne);const Fe=document.getElementById("mode-toggle");function Be(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var i=n.call(t,e||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:e+""}function He(t,e,n){return(e=Be(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}const Ie="sidebar-display";class Re{static toggle(){!1===Re.isExpanded?document.body.setAttribute(Ie,""):document.body.removeAttribute(Ie),Re.isExpanded=!Re.isExpanded}}He(Re,"isExpanded",!1);const ze=document.getElementById("sidebar-trigger"),We=document.getElementById("search-trigger"),qe=document.getElementById("search-cancel"),Ve=document.querySelectorAll("#main-wrapper>.container>.row"),Ue=document.getElementById("topbar-title"),Ye=document.getElementById("search"),Ke=document.getElementById("search-result-wrapper"),Qe=document.getElementById("search-results"),$e=document.getElementById("search-input"),Ge=document.getElementById("search-hints"),Xe="d-block",Je="d-none",Ze="input-focus",tn="d-flex";class en{static on(){ze.classList.add(Je),Ue.classList.add(Je),We.classList.add(Je),Ye.classList.add(tn),qe.classList.add(Xe)}static off(){qe.classList.remove(Xe),Ye.classList.remove(tn),ze.classList.remove(Je),Ue.classList.remove(Je),We.classList.remove(Je)}}class nn{static on(){this.resultVisible||(Ke.classList.remove(Je),Ve.forEach((t=>{t.classList.add(Je)})),this.resultVisible=!0)}static off(){this.resultVisible&&(Qe.innerHTML="",Ge.classList.contains(Je)&&Ge.classList.remove(Je),Ke.classList.add(Je),Ve.forEach((t=>{t.classList.remove(Je)})),$e.textContent="",this.resultVisible=!1)}}function on(){return qe.classList.contains(Xe)}He(nn,"resultVisible",!1);const rn=".".concat("bs.collapse"),sn="show".concat(rn),an="shown".concat(rn),cn="hide".concat(rn),ln="hidden".concat(rn),un="click".concat(rn).concat(".data-api"),fn="show",dn="collapse",pn="collapsing",hn=":scope .".concat(dn," .").concat(dn),mn='[data-bs-toggle="collapse"]',gn={parent:null,toggle:!0},vn={parent:"(null|element)",toggle:"boolean"};class bn extends he{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const n=_e.find(mn);for(const t of n){const e=_e.getSelectorFromElement(t),n=_e.find(e).filter((t=>t===this._element));null!==e&&n.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return gn}static get DefaultType(){return vn}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>bn.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(ce.trigger(this._element,sn).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(dn),this._element.classList.add(pn),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const n=e[0].toUpperCase()+e.slice(1),i="scroll".concat(n);this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn,fn),this._element.style[e]="",ce.trigger(this._element,an)}),this._element,!0),this._element.style[e]="".concat(this._element[i],"px")}hide(){if(this._isTransitioning||!this._isShown())return;if(ce.trigger(this._element,cn).defaultPrevented)return;const t=this._getDimension();this._element.style[t]="".concat(this._element.getBoundingClientRect()[t],"px"),this._element.offsetHeight,this._element.classList.add(pn),this._element.classList.remove(dn,fn);for(const t of this._triggerArray){const e=_e.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0;this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn),ce.trigger(this._element,ln)}),this._element,!0)}_isShown(){return(arguments.length>0&&void 0!==arguments[0]?arguments[0]:this._element).classList.contains(fn)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=Ht(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(mn);for(const e of t){const t=_e.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=_e.find(hn,this._config.parent);return _e.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const n of t)n.classList.toggle("collapsed",!e),n.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const n=bn.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===n[t])throw new TypeError('No method named "'.concat(t,'"'));n[t]()}}))}}ce.on(document,un,mn,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of _e.getMultipleElementsFromSelector(this))bn.getOrCreateInstance(t,{toggle:!1}).toggle()})),Vt(bn),document.getElementsByClassName("collapse");const yn="data-src",_n="data-lqip",wn={SHIMMER:"shimmer",BLUR:"blur"};function En(t){this.parentElement.classList.remove(t)}function xn(){this.complete&&(this.hasAttribute(_n)?En.call(this,wn.BLUR):En.call(this,wn.SHIMMER))}function On(){const t=this.getAttribute(yn);this.setAttribute("src",encodeURI(t)),this.removeAttribute(yn)}class An{static get attrTimestamp(){return"data-ts"}static get attrDateFormat(){return"data-df"}static get locale(){return document.documentElement.getAttribute("lang").substring(0,2)}static getTimestamp(t){return Number(t.getAttribute(this.attrTimestamp))}static getDateFormat(t){return t.getAttribute(this.attrDateFormat)}}!function(){const t=document.querySelectorAll("article img");if(0===t.length)return;t.forEach((t=>{t.addEventListener("load",xn)})),document.querySelectorAll('article img[loading="lazy"]').forEach((t=>{t.complete&&En.call(t,wn.SHIMMER)}));const e=document.querySelectorAll("article img[".concat(_n,'="true"]'));e.length&&e.forEach((t=>{On.call(t)}))}(),dayjs.locale(An.locale),dayjs.extend(window.dayjs_plugin_localizedFormat),document.querySelectorAll("[".concat(An.attrTimestamp,"]")).forEach((t=>{const e=dayjs.unix(An.getTimestamp(t)),n=e.format(An.getDateFormat(t));if(t.textContent=n,t.removeAttribute(An.attrTimestamp),t.removeAttribute(An.attrDateFormat),t.hasAttribute("data-bs-toggle")&&"tooltip"===t.getAttribute("data-bs-toggle")){const n=e.format("llll");t.setAttribute("data-bs-title",n)}})),Fe&&Fe.addEventListener("click",(()=>{modeToggle.flipMode()})),document.getElementById("sidebar-trigger").addEventListener("click",Re.toggle),document.getElementById("mask").addEventListener("click",Re.toggle),We.addEventListener("click",(()=>{en.on(),nn.on(),$e.focus()})),qe.addEventListener("click",(()=>{en.off(),nn.off()})),$e.addEventListener("focus",(()=>{Ye.classList.add(Ze)})),$e.addEventListener("focusout",(()=>{Ye.classList.remove(Ze)})),$e.addEventListener("input",(()=>{""===$e.value?on()?Ge.classList.remove(Je):nn.off():(nn.on(),on()&&Ge.classList.add(Je))})),function(){const t=document.getElementById("back-to-top");window.addEventListener("scroll",(()=>{window.scrollY>50?t.classList.add("show"):t.classList.remove("show")})),t.addEventListener("click",(()=>{window.scrollTo({top:0})}))}(),[...document.querySelectorAll('[data-bs-toggle="tooltip"]')].map((t=>new Ne(t)))}(); diff --git a/assets/js/dist/misc.min.js b/assets/js/dist/misc.min.js new file mode 100644 index 0000000..e101c4a --- /dev/null +++ b/assets/js/dist/misc.min.js @@ -0,0 +1,4 @@ +/*! + * jekyll-theme-chirpy v7.1.1 | © 2019 Cotes Chung | MIT Licensed | https://github.com/cotes2020/jekyll-theme-chirpy/ + */ +!function(){"use strict";var t="top",e="bottom",n="right",i="left",o="auto",r=[t,e,n,i],s="start",a="end",c="clippingParents",l="viewport",u="popper",f="reference",d=r.reduce((function(t,e){return t.concat([e+"-"+s,e+"-"+a])}),[]),p=[].concat(r,[o]).reduce((function(t,e){return t.concat([e,e+"-"+s,e+"-"+a])}),[]),h="beforeRead",m="read",g="afterRead",v="beforeMain",b="main",y="afterMain",_="beforeWrite",w="write",E="afterWrite",x=[h,m,g,v,b,y,_,w,E];function O(t){return t?(t.nodeName||"").toLowerCase():null}function A(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function C(t){return t instanceof A(t).Element||t instanceof Element}function T(t){return t instanceof A(t).HTMLElement||t instanceof HTMLElement}function L(t){return"undefined"!=typeof ShadowRoot&&(t instanceof A(t).ShadowRoot||t instanceof ShadowRoot)}var j={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var n=e.styles[t]||{},i=e.attributes[t]||{},o=e.elements[t];T(o)&&O(o)&&(Object.assign(o.style,n),Object.keys(i).forEach((function(t){var e=i[t];!1===e?o.removeAttribute(t):o.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,n={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,n.popper),e.styles=n,e.elements.arrow&&Object.assign(e.elements.arrow.style,n.arrow),function(){Object.keys(e.elements).forEach((function(t){var i=e.elements[t],o=e.attributes[t]||{},r=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:n[t]).reduce((function(t,e){return t[e]="",t}),{});T(i)&&O(i)&&(Object.assign(i.style,r),Object.keys(o).forEach((function(t){i.removeAttribute(t)})))}))}},requires:["computeStyles"]};function S(t){return t.split("-")[0]}var D=Math.max,k=Math.min,P=Math.round;function N(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function M(){return!/^((?!chrome|android).)*safari/i.test(N())}function F(t,e,n){void 0===e&&(e=!1),void 0===n&&(n=!1);var i=t.getBoundingClientRect(),o=1,r=1;e&&T(t)&&(o=t.offsetWidth>0&&P(i.width)/t.offsetWidth||1,r=t.offsetHeight>0&&P(i.height)/t.offsetHeight||1);var s=(C(t)?A(t):window).visualViewport,a=!M()&&n,c=(i.left+(a&&s?s.offsetLeft:0))/o,l=(i.top+(a&&s?s.offsetTop:0))/r,u=i.width/o,f=i.height/r;return{width:u,height:f,top:l,right:c+u,bottom:l+f,left:c,x:c,y:l}}function B(t){var e=F(t),n=t.offsetWidth,i=t.offsetHeight;return Math.abs(e.width-n)<=1&&(n=e.width),Math.abs(e.height-i)<=1&&(i=e.height),{x:t.offsetLeft,y:t.offsetTop,width:n,height:i}}function H(t,e){var n=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(n&&L(n)){var i=e;do{if(i&&t.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function I(t){return A(t).getComputedStyle(t)}function z(t){return["table","td","th"].indexOf(O(t))>=0}function W(t){return((C(t)?t.ownerDocument:t.document)||window.document).documentElement}function R(t){return"html"===O(t)?t:t.assignedSlot||t.parentNode||(L(t)?t.host:null)||W(t)}function q(t){return T(t)&&"fixed"!==I(t).position?t.offsetParent:null}function V(t){for(var e=A(t),n=q(t);n&&z(n)&&"static"===I(n).position;)n=q(n);return n&&("html"===O(n)||"body"===O(n)&&"static"===I(n).position)?e:n||function(t){var e=/firefox/i.test(N());if(/Trident/i.test(N())&&T(t)&&"fixed"===I(t).position)return null;var n=R(t);for(L(n)&&(n=n.host);T(n)&&["html","body"].indexOf(O(n))<0;){var i=I(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||e&&"filter"===i.willChange||e&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(t)||e}function Y(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function U(t,e,n){return D(t,k(e,n))}function K(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function Q(t,e){return e.reduce((function(e,n){return e[n]=t,e}),{})}var $={name:"arrow",enabled:!0,phase:"main",fn:function(o){var s,a=o.state,c=o.name,l=o.options,u=a.elements.arrow,f=a.modifiersData.popperOffsets,d=S(a.placement),p=Y(d),h=[i,n].indexOf(d)>=0?"height":"width";if(u&&f){var m=function(t,e){return K("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:Q(t,r))}(l.padding,a),g=B(u),v="y"===p?t:i,b="y"===p?e:n,y=a.rects.reference[h]+a.rects.reference[p]-f[p]-a.rects.popper[h],_=f[p]-a.rects.reference[p],w=V(u),E=w?"y"===p?w.clientHeight||0:w.clientWidth||0:0,x=y/2-_/2,O=m[v],A=E-g[h]-m[b],C=E/2-g[h]/2+x,T=U(O,C,A),L=p;a.modifiersData[c]=((s={})[L]=T,s.centerOffset=T-C,s)}},effect:function(t){var e=t.state,n=t.options.element,i=void 0===n?"[data-popper-arrow]":n;null!=i&&("string"!=typeof i||(i=e.elements.popper.querySelector(i)))&&H(e.elements.popper,i)&&(e.elements.arrow=i)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function G(t){return t.split("-")[1]}var X={top:"auto",right:"auto",bottom:"auto",left:"auto"};function J(o){var r,s=o.popper,c=o.popperRect,l=o.placement,u=o.variation,f=o.offsets,d=o.position,p=o.gpuAcceleration,h=o.adaptive,m=o.roundOffsets,g=o.isFixed,v=f.x,b=void 0===v?0:v,y=f.y,_=void 0===y?0:y,w="function"==typeof m?m({x:b,y:_}):{x:b,y:_};b=w.x,_=w.y;var E=f.hasOwnProperty("x"),x=f.hasOwnProperty("y"),O=i,C=t,T=window;if(h){var L=V(s),j="clientHeight",S="clientWidth";if(L===A(s)&&"static"!==I(L=W(s)).position&&"absolute"===d&&(j="scrollHeight",S="scrollWidth"),l===t||(l===i||l===n)&&u===a)C=e,_-=(g&&L===T&&T.visualViewport?T.visualViewport.height:L[j])-c.height,_*=p?1:-1;if(l===i||(l===t||l===e)&&u===a)O=n,b-=(g&&L===T&&T.visualViewport?T.visualViewport.width:L[S])-c.width,b*=p?1:-1}var D,k=Object.assign({position:d},h&&X),N=!0===m?function(t,e){var n=t.x,i=t.y,o=e.devicePixelRatio||1;return{x:P(n*o)/o||0,y:P(i*o)/o||0}}({x:b,y:_},A(s)):{x:b,y:_};return b=N.x,_=N.y,p?Object.assign({},k,((D={})[C]=x?"0":"",D[O]=E?"0":"",D.transform=(T.devicePixelRatio||1)<=1?"translate("+b+"px, "+_+"px)":"translate3d("+b+"px, "+_+"px, 0)",D)):Object.assign({},k,((r={})[C]=x?_+"px":"",r[O]=E?b+"px":"",r.transform="",r))}var Z={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,n=t.options,i=n.gpuAcceleration,o=void 0===i||i,r=n.adaptive,s=void 0===r||r,a=n.roundOffsets,c=void 0===a||a,l={placement:S(e.placement),variation:G(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:o,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,J(Object.assign({},l,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:s,roundOffsets:c})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,J(Object.assign({},l,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:c})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}},tt={passive:!0};var et={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,n=t.instance,i=t.options,o=i.scroll,r=void 0===o||o,s=i.resize,a=void 0===s||s,c=A(e.elements.popper),l=[].concat(e.scrollParents.reference,e.scrollParents.popper);return r&&l.forEach((function(t){t.addEventListener("scroll",n.update,tt)})),a&&c.addEventListener("resize",n.update,tt),function(){r&&l.forEach((function(t){t.removeEventListener("scroll",n.update,tt)})),a&&c.removeEventListener("resize",n.update,tt)}},data:{}},nt={left:"right",right:"left",bottom:"top",top:"bottom"};function it(t){return t.replace(/left|right|bottom|top/g,(function(t){return nt[t]}))}var ot={start:"end",end:"start"};function rt(t){return t.replace(/start|end/g,(function(t){return ot[t]}))}function st(t){var e=A(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function at(t){return F(W(t)).left+st(t).scrollLeft}function ct(t){var e=I(t),n=e.overflow,i=e.overflowX,o=e.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+i)}function lt(t){return["html","body","#document"].indexOf(O(t))>=0?t.ownerDocument.body:T(t)&&ct(t)?t:lt(R(t))}function ut(t,e){var n;void 0===e&&(e=[]);var i=lt(t),o=i===(null==(n=t.ownerDocument)?void 0:n.body),r=A(i),s=o?[r].concat(r.visualViewport||[],ct(i)?i:[]):i,a=e.concat(s);return o?a:a.concat(ut(R(s)))}function ft(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function dt(t,e,n){return e===l?ft(function(t,e){var n=A(t),i=W(t),o=n.visualViewport,r=i.clientWidth,s=i.clientHeight,a=0,c=0;if(o){r=o.width,s=o.height;var l=M();(l||!l&&"fixed"===e)&&(a=o.offsetLeft,c=o.offsetTop)}return{width:r,height:s,x:a+at(t),y:c}}(t,n)):C(e)?function(t,e){var n=F(t,!1,"fixed"===e);return n.top=n.top+t.clientTop,n.left=n.left+t.clientLeft,n.bottom=n.top+t.clientHeight,n.right=n.left+t.clientWidth,n.width=t.clientWidth,n.height=t.clientHeight,n.x=n.left,n.y=n.top,n}(e,n):ft(function(t){var e,n=W(t),i=st(t),o=null==(e=t.ownerDocument)?void 0:e.body,r=D(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=D(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),a=-i.scrollLeft+at(t),c=-i.scrollTop;return"rtl"===I(o||n).direction&&(a+=D(n.clientWidth,o?o.clientWidth:0)-r),{width:r,height:s,x:a,y:c}}(W(t)))}function pt(t,e,n,i){var o="clippingParents"===e?function(t){var e=ut(R(t)),n=["absolute","fixed"].indexOf(I(t).position)>=0&&T(t)?V(t):t;return C(n)?e.filter((function(t){return C(t)&&H(t,n)&&"body"!==O(t)})):[]}(t):[].concat(e),r=[].concat(o,[n]),s=r[0],a=r.reduce((function(e,n){var o=dt(t,n,i);return e.top=D(o.top,e.top),e.right=k(o.right,e.right),e.bottom=k(o.bottom,e.bottom),e.left=D(o.left,e.left),e}),dt(t,s,i));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function ht(o){var r,c=o.reference,l=o.element,u=o.placement,f=u?S(u):null,d=u?G(u):null,p=c.x+c.width/2-l.width/2,h=c.y+c.height/2-l.height/2;switch(f){case t:r={x:p,y:c.y-l.height};break;case e:r={x:p,y:c.y+c.height};break;case n:r={x:c.x+c.width,y:h};break;case i:r={x:c.x-l.width,y:h};break;default:r={x:c.x,y:c.y}}var m=f?Y(f):null;if(null!=m){var g="y"===m?"height":"width";switch(d){case s:r[m]=r[m]-(c[g]/2-l[g]/2);break;case a:r[m]=r[m]+(c[g]/2-l[g]/2)}}return r}function mt(i,o){void 0===o&&(o={});var s=o,a=s.placement,d=void 0===a?i.placement:a,p=s.strategy,h=void 0===p?i.strategy:p,m=s.boundary,g=void 0===m?c:m,v=s.rootBoundary,b=void 0===v?l:v,y=s.elementContext,_=void 0===y?u:y,w=s.altBoundary,E=void 0!==w&&w,x=s.padding,O=void 0===x?0:x,A=K("number"!=typeof O?O:Q(O,r)),T=_===u?f:u,L=i.rects.popper,j=i.elements[E?T:_],S=pt(C(j)?j:j.contextElement||W(i.elements.popper),g,b,h),D=F(i.elements.reference),k=ht({reference:D,element:L,strategy:"absolute",placement:d}),P=ft(Object.assign({},L,k)),N=_===u?P:D,M={top:S.top-N.top+A.top,bottom:N.bottom-S.bottom+A.bottom,left:S.left-N.left+A.left,right:N.right-S.right+A.right},B=i.modifiersData.offset;if(_===u&&B){var H=B[d];Object.keys(M).forEach((function(i){var o=[n,e].indexOf(i)>=0?1:-1,r=[t,e].indexOf(i)>=0?"y":"x";M[i]+=H[r]*o}))}return M}function gt(t,e){void 0===e&&(e={});var n=e,i=n.placement,o=n.boundary,s=n.rootBoundary,a=n.padding,c=n.flipVariations,l=n.allowedAutoPlacements,u=void 0===l?p:l,f=G(i),h=f?c?d:d.filter((function(t){return G(t)===f})):r,m=h.filter((function(t){return u.indexOf(t)>=0}));0===m.length&&(m=h);var g=m.reduce((function(e,n){return e[n]=mt(t,{placement:n,boundary:o,rootBoundary:s,padding:a})[S(n)],e}),{});return Object.keys(g).sort((function(t,e){return g[t]-g[e]}))}var vt={name:"flip",enabled:!0,phase:"main",fn:function(r){var a=r.state,c=r.options,l=r.name;if(!a.modifiersData[l]._skip){for(var u=c.mainAxis,f=void 0===u||u,d=c.altAxis,p=void 0===d||d,h=c.fallbackPlacements,m=c.padding,g=c.boundary,v=c.rootBoundary,b=c.altBoundary,y=c.flipVariations,_=void 0===y||y,w=c.allowedAutoPlacements,E=a.options.placement,x=S(E),O=h||(x===E||!_?[it(E)]:function(t){if(S(t)===o)return[];var e=it(t);return[rt(t),e,rt(e)]}(E)),A=[E].concat(O).reduce((function(t,e){return t.concat(S(e)===o?gt(a,{placement:e,boundary:g,rootBoundary:v,padding:m,flipVariations:_,allowedAutoPlacements:w}):e)}),[]),C=a.rects.reference,T=a.rects.popper,L=new Map,j=!0,D=A[0],k=0;k=0,B=F?"width":"height",H=mt(a,{placement:P,boundary:g,rootBoundary:v,altBoundary:b,padding:m}),I=F?M?n:i:M?e:t;C[B]>T[B]&&(I=it(I));var z=it(I),W=[];if(f&&W.push(H[N]<=0),p&&W.push(H[I]<=0,H[z]<=0),W.every((function(t){return t}))){D=P,j=!1;break}L.set(P,W)}if(j)for(var R=function(t){var e=A.find((function(e){var n=L.get(e);if(n)return n.slice(0,t).every((function(t){return t}))}));if(e)return D=e,"break"},q=_?3:1;q>0;q--){if("break"===R(q))break}a.placement!==D&&(a.modifiersData[l]._skip=!0,a.placement=D,a.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function bt(t,e,n){return void 0===n&&(n={x:0,y:0}),{top:t.top-e.height-n.y,right:t.right-e.width+n.x,bottom:t.bottom-e.height+n.y,left:t.left-e.width-n.x}}function yt(o){return[t,n,e,i].some((function(t){return o[t]>=0}))}var _t={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,n=t.name,i=e.rects.reference,o=e.rects.popper,r=e.modifiersData.preventOverflow,s=mt(e,{elementContext:"reference"}),a=mt(e,{altBoundary:!0}),c=bt(s,i),l=bt(a,o,r),u=yt(c),f=yt(l);e.modifiersData[n]={referenceClippingOffsets:c,popperEscapeOffsets:l,isReferenceHidden:u,hasPopperEscaped:f},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":u,"data-popper-escaped":f})}};var wt={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(e){var o=e.state,r=e.options,s=e.name,a=r.offset,c=void 0===a?[0,0]:a,l=p.reduce((function(e,r){return e[r]=function(e,o,r){var s=S(e),a=[i,t].indexOf(s)>=0?-1:1,c="function"==typeof r?r(Object.assign({},o,{placement:e})):r,l=c[0],u=c[1];return l=l||0,u=(u||0)*a,[i,n].indexOf(s)>=0?{x:u,y:l}:{x:l,y:u}}(r,o.rects,c),e}),{}),u=l[o.placement],f=u.x,d=u.y;null!=o.modifiersData.popperOffsets&&(o.modifiersData.popperOffsets.x+=f,o.modifiersData.popperOffsets.y+=d),o.modifiersData[s]=l}};var Et={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,n=t.name;e.modifiersData[n]=ht({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}};var xt={name:"preventOverflow",enabled:!0,phase:"main",fn:function(o){var r=o.state,a=o.options,c=o.name,l=a.mainAxis,u=void 0===l||l,f=a.altAxis,d=void 0!==f&&f,p=a.boundary,h=a.rootBoundary,m=a.altBoundary,g=a.padding,v=a.tether,b=void 0===v||v,y=a.tetherOffset,_=void 0===y?0:y,w=mt(r,{boundary:p,rootBoundary:h,padding:g,altBoundary:m}),E=S(r.placement),x=G(r.placement),O=!x,A=Y(E),C="x"===A?"y":"x",T=r.modifiersData.popperOffsets,L=r.rects.reference,j=r.rects.popper,P="function"==typeof _?_(Object.assign({},r.rects,{placement:r.placement})):_,N="number"==typeof P?{mainAxis:P,altAxis:P}:Object.assign({mainAxis:0,altAxis:0},P),M=r.modifiersData.offset?r.modifiersData.offset[r.placement]:null,F={x:0,y:0};if(T){if(u){var H,I="y"===A?t:i,z="y"===A?e:n,W="y"===A?"height":"width",R=T[A],q=R+w[I],K=R-w[z],Q=b?-j[W]/2:0,$=x===s?L[W]:j[W],X=x===s?-j[W]:-L[W],J=r.elements.arrow,Z=b&&J?B(J):{width:0,height:0},tt=r.modifiersData["arrow#persistent"]?r.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},et=tt[I],nt=tt[z],it=U(0,L[W],Z[W]),ot=O?L[W]/2-Q-it-et-N.mainAxis:$-it-et-N.mainAxis,rt=O?-L[W]/2+Q+it+nt+N.mainAxis:X+it+nt+N.mainAxis,st=r.elements.arrow&&V(r.elements.arrow),at=st?"y"===A?st.clientTop||0:st.clientLeft||0:0,ct=null!=(H=null==M?void 0:M[A])?H:0,lt=R+rt-ct,ut=U(b?k(q,R+ot-ct-at):q,R,b?D(K,lt):K);T[A]=ut,F[A]=ut-R}if(d){var ft,dt="x"===A?t:i,pt="x"===A?e:n,ht=T[C],gt="y"===C?"height":"width",vt=ht+w[dt],bt=ht-w[pt],yt=-1!==[t,i].indexOf(E),_t=null!=(ft=null==M?void 0:M[C])?ft:0,wt=yt?vt:ht-L[gt]-j[gt]-_t+N.altAxis,Et=yt?ht+L[gt]+j[gt]-_t-N.altAxis:bt,xt=b&&yt?function(t,e,n){var i=U(t,e,n);return i>n?n:i}(wt,ht,Et):U(b?wt:vt,ht,b?Et:bt);T[C]=xt,F[C]=xt-ht}r.modifiersData[c]=F}},requiresIfExists:["offset"]};function Ot(t,e,n){void 0===n&&(n=!1);var i,o,r=T(e),s=T(e)&&function(t){var e=t.getBoundingClientRect(),n=P(e.width)/t.offsetWidth||1,i=P(e.height)/t.offsetHeight||1;return 1!==n||1!==i}(e),a=W(e),c=F(t,s,n),l={scrollLeft:0,scrollTop:0},u={x:0,y:0};return(r||!r&&!n)&&(("body"!==O(e)||ct(a))&&(l=(i=e)!==A(i)&&T(i)?{scrollLeft:(o=i).scrollLeft,scrollTop:o.scrollTop}:st(i)),T(e)?((u=F(e,!0)).x+=e.clientLeft,u.y+=e.clientTop):a&&(u.x=at(a))),{x:c.left+l.scrollLeft-u.x,y:c.top+l.scrollTop-u.y,width:c.width,height:c.height}}function At(t){var e=new Map,n=new Set,i=[];function o(t){n.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!n.has(t)){var i=e.get(t);i&&o(i)}})),i.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){n.has(t.name)||o(t)})),i}var Ct={placement:"bottom",modifiers:[],strategy:"absolute"};function Tt(){for(var t=arguments.length,e=new Array(t),n=0;nPt.has(t)&&Pt.get(t).get(e)||null,remove(t,e){if(!Pt.has(t))return;const n=Pt.get(t);n.delete(e),0===n.size&&Pt.delete(t)}};const Mt="transitionend",Ft=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>"#".concat(CSS.escape(e))))),t),Bt=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),Ht=t=>Bt(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(Ft(t)):null,It=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?It(t.parentNode):null},zt=()=>{},Wt=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,Rt=[],qt=()=>"rtl"===document.documentElement.dir,Vt=t=>{var e;e=()=>{const e=Wt();if(e){const n=t.NAME,i=e.fn[n];e.fn[n]=t.jQueryInterface,e.fn[n].Constructor=t,e.fn[n].noConflict=()=>(e.fn[n]=i,t.jQueryInterface)}},"loading"===document.readyState?(Rt.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of Rt)t()})),Rt.push(e)):e()},Yt=function(t){let e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:t;return"function"==typeof t?t(...arguments.length>1&&void 0!==arguments[1]?arguments[1]:[]):e},Ut=function(t,e){if(!(!(arguments.length>2&&void 0!==arguments[2])||arguments[2]))return void Yt(t);const n=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:n}=window.getComputedStyle(t);const i=Number.parseFloat(e),o=Number.parseFloat(n);return i||o?(e=e.split(",")[0],n=n.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(n))):0})(e)+5;let i=!1;const o=n=>{let{target:r}=n;r===e&&(i=!0,e.removeEventListener(Mt,o),Yt(t))};e.addEventListener(Mt,o),setTimeout((()=>{i||e.dispatchEvent(new Event(Mt))}),n)},Kt=/[^.]*(?=\..*)\.|.*/,Qt=/\..*/,$t=/::\d+$/,Gt={};let Xt=1;const Jt={mouseenter:"mouseover",mouseleave:"mouseout"},Zt=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function te(t,e){return e&&"".concat(e,"::").concat(Xt++)||t.uidEvent||Xt++}function ee(t){const e=te(t);return t.uidEvent=e,Gt[e]=Gt[e]||{},Gt[e]}function ne(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===n))}function ie(t,e,n){const i="string"==typeof e,o=i?n:e||n;let r=ae(t);return Zt.has(r)||(r=t),[i,o,r]}function oe(t,e,n,i,o){if("string"!=typeof e||!t)return;let[r,s,a]=ie(e,n,i);if(e in Jt){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};s=t(s)}const c=ee(t),l=c[a]||(c[a]={}),u=ne(l,s,r?n:null);if(u)return void(u.oneOff=u.oneOff&&o);const f=te(s,e.replace(Kt,"")),d=r?function(t,e,n){return function i(o){const r=t.querySelectorAll(e);for(let{target:s}=o;s&&s!==this;s=s.parentNode)for(const a of r)if(a===s)return le(o,{delegateTarget:s}),i.oneOff&&ce.off(t,o.type,e,n),n.apply(s,[o])}}(t,n,s):function(t,e){return function n(i){return le(i,{delegateTarget:t}),n.oneOff&&ce.off(t,i.type,e),e.apply(t,[i])}}(t,s);d.delegationSelector=r?n:null,d.callable=s,d.oneOff=o,d.uidEvent=f,l[f]=d,t.addEventListener(a,d,r)}function re(t,e,n,i,o){const r=ne(e[n],i,o);r&&(t.removeEventListener(n,r,Boolean(o)),delete e[n][r.uidEvent])}function se(t,e,n,i){const o=e[n]||{};for(const[r,s]of Object.entries(o))r.includes(i)&&re(t,e,n,s.callable,s.delegationSelector)}function ae(t){return t=t.replace(Qt,""),Jt[t]||t}const ce={on(t,e,n,i){oe(t,e,n,i,!1)},one(t,e,n,i){oe(t,e,n,i,!0)},off(t,e,n,i){if("string"!=typeof e||!t)return;const[o,r,s]=ie(e,n,i),a=s!==e,c=ee(t),l=c[s]||{},u=e.startsWith(".");if(void 0===r){if(u)for(const n of Object.keys(c))se(t,c,n,e.slice(1));for(const[n,i]of Object.entries(l)){const o=n.replace($t,"");a&&!e.includes(o)||re(t,c,s,i.callable,i.delegationSelector)}}else{if(!Object.keys(l).length)return;re(t,c,s,r,o?n:null)}},trigger(t,e,n){if("string"!=typeof e||!t)return null;const i=Wt();let o=null,r=!0,s=!0,a=!1;e!==ae(e)&&i&&(o=i.Event(e,n),i(t).trigger(o),r=!o.isPropagationStopped(),s=!o.isImmediatePropagationStopped(),a=o.isDefaultPrevented());const c=le(new Event(e,{bubbles:r,cancelable:!0}),n);return a&&c.preventDefault(),s&&t.dispatchEvent(c),c.defaultPrevented&&o&&o.preventDefault(),c}};function le(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(const[n,i]of Object.entries(e))try{t[n]=i}catch{Object.defineProperty(t,n,{configurable:!0,get:()=>i})}return t}function ue(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch{return t}}function fe(t){return t.replace(/[A-Z]/g,(t=>"-".concat(t.toLowerCase())))}const de={setDataAttribute(t,e,n){t.setAttribute("data-bs-".concat(fe(e)),n)},removeDataAttribute(t,e){t.removeAttribute("data-bs-".concat(fe(e)))},getDataAttributes(t){if(!t)return{};const e={},n=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const i of n){let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=ue(t.dataset[i])}return e},getDataAttribute:(t,e)=>ue(t.getAttribute("data-bs-".concat(fe(e))))};class pe{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const n=Bt(e)?de.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof n?n:{},...Bt(e)?de.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.constructor.DefaultType;for(const[i,o]of Object.entries(e)){const e=t[i],r=Bt(e)?"element":null==(n=e)?"".concat(n):Object.prototype.toString.call(n).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(o).test(r))throw new TypeError("".concat(this.constructor.NAME.toUpperCase(),': Option "').concat(i,'" provided type "').concat(r,'" but expected type "').concat(o,'".'))}var n}}class he extends pe{constructor(t,e){super(),(t=Ht(t))&&(this._element=t,this._config=this._getConfig(e),Nt.set(this._element,this.constructor.DATA_KEY,this))}dispose(){Nt.remove(this._element,this.constructor.DATA_KEY),ce.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e){Ut(t,e,!(arguments.length>2&&void 0!==arguments[2])||arguments[2])}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return Nt.get(Ht(t),this.DATA_KEY)}static getOrCreateInstance(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return"bs.".concat(this.NAME)}static get EVENT_KEY(){return".".concat(this.DATA_KEY)}static eventName(t){return"".concat(t).concat(this.EVENT_KEY)}}const me={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},ge=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),ve=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,be=(t,e)=>{const n=t.nodeName.toLowerCase();return e.includes(n)?!ge.has(n)||Boolean(ve.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(n)))};const ye=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let n=t.getAttribute("href");if(!n||!n.includes("#")&&!n.startsWith("."))return null;n.includes("#")&&!n.startsWith("#")&&(n="#".concat(n.split("#")[1])),e=n&&"#"!==n?n.trim():null}return e?e.split(",").map((t=>Ft(t))).join(","):null},_e={find(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return[].concat(...Element.prototype.querySelectorAll.call(e,t))},findOne(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return Element.prototype.querySelector.call(e,t)},children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const n=[];let i=t.parentNode.closest(e);for(;i;)n.push(i),i=i.parentNode.closest(e);return n},prev(t,e){let n=t.previousElementSibling;for(;n;){if(n.matches(e))return[n];n=n.previousElementSibling}return[]},next(t,e){let n=t.nextElementSibling;for(;n;){if(n.matches(e))return[n];n=n.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>"".concat(t,':not([tabindex^="-"])'))).join(",");return this.find(e,t).filter((t=>!(t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")))(t)&&(t=>{if(!Bt(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),n=t.closest("details:not([open])");if(!n)return e;if(n!==t){const e=t.closest("summary");if(e&&e.parentNode!==n)return!1;if(null===e)return!1}return e})(t)))},getSelectorFromElement(t){const e=ye(t);return e&&_e.findOne(e)?e:null},getElementFromSelector(t){const e=ye(t);return e?_e.findOne(e):null},getMultipleElementsFromSelector(t){const e=ye(t);return e?_e.find(e):[]}},we={allowList:me,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Ee={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},xe={entry:"(string|element|function|null)",selector:"(string|element)"};class Oe extends pe{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return we}static get DefaultType(){return Ee}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,n]of Object.entries(this._config.content))this._setContent(t,n,e);const e=t.children[0],n=this._resolvePossibleFunction(this._config.extraClass);return n&&e.classList.add(...n.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,n]of Object.entries(t))super._typeCheckConfig({selector:e,entry:n},xe)}_setContent(t,e,n){const i=_e.findOne(n,t);i&&((e=this._resolvePossibleFunction(e))?Bt(e)?this._putElementInTemplate(Ht(e),i):this._config.html?i.innerHTML=this._maybeSanitize(e):i.textContent=e:i.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,n){if(!t.length)return t;if(n&&"function"==typeof n)return n(t);const i=(new window.DOMParser).parseFromString(t,"text/html"),o=[].concat(...i.body.querySelectorAll("*"));for(const t of o){const n=t.nodeName.toLowerCase();if(!Object.keys(e).includes(n)){t.remove();continue}const i=[].concat(...t.attributes),o=[].concat(e["*"]||[],e[n]||[]);for(const e of i)be(e,o)||t.removeAttribute(e.nodeName)}return i.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return Yt(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Ae=new Set(["sanitize","allowList","sanitizeFn"]),Ce="fade",Te="show",Le=".".concat("modal"),je="hide.bs.modal",Se="hover",De="focus",ke={AUTO:"auto",TOP:"top",RIGHT:qt()?"left":"right",BOTTOM:"bottom",LEFT:qt()?"right":"left"},Pe={allowList:me,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Ne={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Me extends he{constructor(t,e){if(void 0===kt)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Pe}static get DefaultType(){return Ne}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),ce.off(this._element.closest(Le),je,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=ce.trigger(this._element,this.constructor.eventName("show")),e=(It(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const n=this._getTipElement();this._element.setAttribute("aria-describedby",n.getAttribute("id"));const{container:i}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(i.append(n),ce.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(n),n.classList.add(Te),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))ce.on(t,"mouseover",zt);this._queueCallback((()=>{ce.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(!this._isShown())return;if(ce.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented)return;if(this._getTipElement().classList.remove(Te),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))ce.off(t,"mouseover",zt);this._activeTrigger.click=!1,this._activeTrigger[De]=!1,this._activeTrigger[Se]=!1,this._isHovered=null;this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),ce.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ce,Te),e.classList.add("bs-".concat(this.constructor.NAME,"-auto"));const n=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",n),this._isAnimated()&&e.classList.add(Ce),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Oe({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ce)}_isShown(){return this.tip&&this.tip.classList.contains(Te)}_createPopper(t){const e=Yt(this._config.placement,[this,t,this._element]),n=ke[e.toUpperCase()];return Dt(this._element,t,this._getPopperConfig(n))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return Yt(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:".".concat(this.constructor.NAME,"-arrow")}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...Yt(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)ce.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===Se?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),n=e===Se?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");ce.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?De:Se]=!0,e._enter()})),ce.on(this._element,n,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?De:Se]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},ce.on(this._element.closest(Le),je,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=de.getDataAttributes(this._element);for(const t of Object.keys(e))Ae.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:Ht(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,n]of Object.entries(this._config))this.constructor.Default[e]!==n&&(t[e]=n);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=Me.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError('No method named "'.concat(t,'"'));e[t]()}}))}}Vt(Me);const Fe=document.getElementById("mode-toggle");function Be(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var i=n.call(t,e||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:e+""}function He(t,e,n){return(e=Be(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}const Ie="sidebar-display";class ze{static toggle(){!1===ze.isExpanded?document.body.setAttribute(Ie,""):document.body.removeAttribute(Ie),ze.isExpanded=!ze.isExpanded}}He(ze,"isExpanded",!1);const We=document.getElementById("sidebar-trigger"),Re=document.getElementById("search-trigger"),qe=document.getElementById("search-cancel"),Ve=document.querySelectorAll("#main-wrapper>.container>.row"),Ye=document.getElementById("topbar-title"),Ue=document.getElementById("search"),Ke=document.getElementById("search-result-wrapper"),Qe=document.getElementById("search-results"),$e=document.getElementById("search-input"),Ge=document.getElementById("search-hints"),Xe="d-block",Je="d-none",Ze="input-focus",tn="d-flex";class en{static on(){We.classList.add(Je),Ye.classList.add(Je),Re.classList.add(Je),Ue.classList.add(tn),qe.classList.add(Xe)}static off(){qe.classList.remove(Xe),Ue.classList.remove(tn),We.classList.remove(Je),Ye.classList.remove(Je),Re.classList.remove(Je)}}class nn{static on(){this.resultVisible||(Ke.classList.remove(Je),Ve.forEach((t=>{t.classList.add(Je)})),this.resultVisible=!0)}static off(){this.resultVisible&&(Qe.innerHTML="",Ge.classList.contains(Je)&&Ge.classList.remove(Je),Ke.classList.add(Je),Ve.forEach((t=>{t.classList.remove(Je)})),$e.textContent="",this.resultVisible=!1)}}function on(){return qe.classList.contains(Xe)}He(nn,"resultVisible",!1);const rn=".".concat("bs.collapse"),sn="show".concat(rn),an="shown".concat(rn),cn="hide".concat(rn),ln="hidden".concat(rn),un="click".concat(rn).concat(".data-api"),fn="show",dn="collapse",pn="collapsing",hn=":scope .".concat(dn," .").concat(dn),mn='[data-bs-toggle="collapse"]',gn={parent:null,toggle:!0},vn={parent:"(null|element)",toggle:"boolean"};class bn extends he{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const n=_e.find(mn);for(const t of n){const e=_e.getSelectorFromElement(t),n=_e.find(e).filter((t=>t===this._element));null!==e&&n.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return gn}static get DefaultType(){return vn}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>bn.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(ce.trigger(this._element,sn).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(dn),this._element.classList.add(pn),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const n=e[0].toUpperCase()+e.slice(1),i="scroll".concat(n);this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn,fn),this._element.style[e]="",ce.trigger(this._element,an)}),this._element,!0),this._element.style[e]="".concat(this._element[i],"px")}hide(){if(this._isTransitioning||!this._isShown())return;if(ce.trigger(this._element,cn).defaultPrevented)return;const t=this._getDimension();this._element.style[t]="".concat(this._element.getBoundingClientRect()[t],"px"),this._element.offsetHeight,this._element.classList.add(pn),this._element.classList.remove(dn,fn);for(const t of this._triggerArray){const e=_e.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0;this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn),ce.trigger(this._element,ln)}),this._element,!0)}_isShown(){return(arguments.length>0&&void 0!==arguments[0]?arguments[0]:this._element).classList.contains(fn)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=Ht(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(mn);for(const e of t){const t=_e.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=_e.find(hn,this._config.parent);return _e.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const n of t)n.classList.toggle("collapsed",!e),n.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const n=bn.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===n[t])throw new TypeError('No method named "'.concat(t,'"'));n[t]()}}))}}ce.on(document,un,mn,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of _e.getMultipleElementsFromSelector(this))bn.getOrCreateInstance(t,{toggle:!1}).toggle()})),Vt(bn),document.getElementsByClassName("collapse");class yn{static get attrTimestamp(){return"data-ts"}static get attrDateFormat(){return"data-df"}static get locale(){return document.documentElement.getAttribute("lang").substring(0,2)}static getTimestamp(t){return Number(t.getAttribute(this.attrTimestamp))}static getDateFormat(t){return t.getAttribute(this.attrDateFormat)}}Fe&&Fe.addEventListener("click",(()=>{modeToggle.flipMode()})),document.getElementById("sidebar-trigger").addEventListener("click",ze.toggle),document.getElementById("mask").addEventListener("click",ze.toggle),Re.addEventListener("click",(()=>{en.on(),nn.on(),$e.focus()})),qe.addEventListener("click",(()=>{en.off(),nn.off()})),$e.addEventListener("focus",(()=>{Ue.classList.add(Ze)})),$e.addEventListener("focusout",(()=>{Ue.classList.remove(Ze)})),$e.addEventListener("input",(()=>{""===$e.value?on()?Ge.classList.remove(Je):nn.off():(nn.on(),on()&&Ge.classList.add(Je))})),dayjs.locale(yn.locale),dayjs.extend(window.dayjs_plugin_localizedFormat),document.querySelectorAll("[".concat(yn.attrTimestamp,"]")).forEach((t=>{const e=dayjs.unix(yn.getTimestamp(t)),n=e.format(yn.getDateFormat(t));if(t.textContent=n,t.removeAttribute(yn.attrTimestamp),t.removeAttribute(yn.attrDateFormat),t.hasAttribute("data-bs-toggle")&&"tooltip"===t.getAttribute("data-bs-toggle")){const n=e.format("llll");t.setAttribute("data-bs-title",n)}})),function(){const t=document.getElementById("back-to-top");window.addEventListener("scroll",(()=>{window.scrollY>50?t.classList.add("show"):t.classList.remove("show")})),t.addEventListener("click",(()=>{window.scrollTo({top:0})}))}(),[...document.querySelectorAll('[data-bs-toggle="tooltip"]')].map((t=>new Me(t)))}(); diff --git a/assets/js/dist/page.min.js b/assets/js/dist/page.min.js new file mode 100644 index 0000000..64cecbc --- /dev/null +++ b/assets/js/dist/page.min.js @@ -0,0 +1,4 @@ +/*! + * jekyll-theme-chirpy v7.1.1 | © 2019 Cotes Chung | MIT Licensed | https://github.com/cotes2020/jekyll-theme-chirpy/ + */ +!function(){"use strict";var e="top",t="bottom",n="right",i="left",o="auto",r=[e,t,n,i],s="start",a="end",c="clippingParents",l="viewport",u="popper",f="reference",d=r.reduce((function(e,t){return e.concat([t+"-"+s,t+"-"+a])}),[]),p=[].concat(r,[o]).reduce((function(e,t){return e.concat([t,t+"-"+s,t+"-"+a])}),[]),h="beforeRead",m="read",g="afterRead",v="beforeMain",b="main",y="afterMain",_="beforeWrite",w="write",E="afterWrite",A=[h,m,g,v,b,y,_,w,E];function x(e){return e?(e.nodeName||"").toLowerCase():null}function O(e){if(null==e)return window;if("[object Window]"!==e.toString()){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function C(e){return e instanceof O(e).Element||e instanceof Element}function L(e){return e instanceof O(e).HTMLElement||e instanceof HTMLElement}function T(e){return"undefined"!=typeof ShadowRoot&&(e instanceof O(e).ShadowRoot||e instanceof ShadowRoot)}var S={name:"applyStyles",enabled:!0,phase:"write",fn:function(e){var t=e.state;Object.keys(t.elements).forEach((function(e){var n=t.styles[e]||{},i=t.attributes[e]||{},o=t.elements[e];L(o)&&x(o)&&(Object.assign(o.style,n),Object.keys(i).forEach((function(e){var t=i[e];!1===t?o.removeAttribute(e):o.setAttribute(e,!0===t?"":t)})))}))},effect:function(e){var t=e.state,n={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.styles=n,t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach((function(e){var i=t.elements[e],o=t.attributes[e]||{},r=Object.keys(t.styles.hasOwnProperty(e)?t.styles[e]:n[e]).reduce((function(e,t){return e[t]="",e}),{});L(i)&&x(i)&&(Object.assign(i.style,r),Object.keys(o).forEach((function(e){i.removeAttribute(e)})))}))}},requires:["computeStyles"]};function j(e){return e.split("-")[0]}var D=Math.max,k=Math.min,M=Math.round;function P(){var e=navigator.userAgentData;return null!=e&&e.brands&&Array.isArray(e.brands)?e.brands.map((function(e){return e.brand+"/"+e.version})).join(" "):navigator.userAgent}function N(){return!/^((?!chrome|android).)*safari/i.test(P())}function I(e,t,n){void 0===t&&(t=!1),void 0===n&&(n=!1);var i=e.getBoundingClientRect(),o=1,r=1;t&&L(e)&&(o=e.offsetWidth>0&&M(i.width)/e.offsetWidth||1,r=e.offsetHeight>0&&M(i.height)/e.offsetHeight||1);var s=(C(e)?O(e):window).visualViewport,a=!N()&&n,c=(i.left+(a&&s?s.offsetLeft:0))/o,l=(i.top+(a&&s?s.offsetTop:0))/r,u=i.width/o,f=i.height/r;return{width:u,height:f,top:l,right:c+u,bottom:l+f,left:c,x:c,y:l}}function B(e){var t=I(e),n=e.offsetWidth,i=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-i)<=1&&(i=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:i}}function F(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&T(n)){var i=t;do{if(i&&e.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function H(e){return O(e).getComputedStyle(e)}function q(e){return["table","td","th"].indexOf(x(e))>=0}function R(e){return((C(e)?e.ownerDocument:e.document)||window.document).documentElement}function z(e){return"html"===x(e)?e:e.assignedSlot||e.parentNode||(T(e)?e.host:null)||R(e)}function W(e){return L(e)&&"fixed"!==H(e).position?e.offsetParent:null}function V(e){for(var t=O(e),n=W(e);n&&q(n)&&"static"===H(n).position;)n=W(n);return n&&("html"===x(n)||"body"===x(n)&&"static"===H(n).position)?t:n||function(e){var t=/firefox/i.test(P());if(/Trident/i.test(P())&&L(e)&&"fixed"===H(e).position)return null;var n=z(e);for(T(n)&&(n=n.host);L(n)&&["html","body"].indexOf(x(n))<0;){var i=H(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||t&&"filter"===i.willChange||t&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(e)||t}function U(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function Y(e,t,n){return D(e,k(t,n))}function K(e){return Object.assign({},{top:0,right:0,bottom:0,left:0},e)}function Q(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}var G={name:"arrow",enabled:!0,phase:"main",fn:function(o){var s,a=o.state,c=o.name,l=o.options,u=a.elements.arrow,f=a.modifiersData.popperOffsets,d=j(a.placement),p=U(d),h=[i,n].indexOf(d)>=0?"height":"width";if(u&&f){var m=function(e,t){return K("number"!=typeof(e="function"==typeof e?e(Object.assign({},t.rects,{placement:t.placement})):e)?e:Q(e,r))}(l.padding,a),g=B(u),v="y"===p?e:i,b="y"===p?t:n,y=a.rects.reference[h]+a.rects.reference[p]-f[p]-a.rects.popper[h],_=f[p]-a.rects.reference[p],w=V(u),E=w?"y"===p?w.clientHeight||0:w.clientWidth||0:0,A=y/2-_/2,x=m[v],O=E-g[h]-m[b],C=E/2-g[h]/2+A,L=Y(x,C,O),T=p;a.modifiersData[c]=((s={})[T]=L,s.centerOffset=L-C,s)}},effect:function(e){var t=e.state,n=e.options.element,i=void 0===n?"[data-popper-arrow]":n;null!=i&&("string"!=typeof i||(i=t.elements.popper.querySelector(i)))&&F(t.elements.popper,i)&&(t.elements.arrow=i)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function $(e){return e.split("-")[1]}var J={top:"auto",right:"auto",bottom:"auto",left:"auto"};function X(o){var r,s=o.popper,c=o.popperRect,l=o.placement,u=o.variation,f=o.offsets,d=o.position,p=o.gpuAcceleration,h=o.adaptive,m=o.roundOffsets,g=o.isFixed,v=f.x,b=void 0===v?0:v,y=f.y,_=void 0===y?0:y,w="function"==typeof m?m({x:b,y:_}):{x:b,y:_};b=w.x,_=w.y;var E=f.hasOwnProperty("x"),A=f.hasOwnProperty("y"),x=i,C=e,L=window;if(h){var T=V(s),S="clientHeight",j="clientWidth";if(T===O(s)&&"static"!==H(T=R(s)).position&&"absolute"===d&&(S="scrollHeight",j="scrollWidth"),l===e||(l===i||l===n)&&u===a)C=t,_-=(g&&T===L&&L.visualViewport?L.visualViewport.height:T[S])-c.height,_*=p?1:-1;if(l===i||(l===e||l===t)&&u===a)x=n,b-=(g&&T===L&&L.visualViewport?L.visualViewport.width:T[j])-c.width,b*=p?1:-1}var D,k=Object.assign({position:d},h&&J),P=!0===m?function(e,t){var n=e.x,i=e.y,o=t.devicePixelRatio||1;return{x:M(n*o)/o||0,y:M(i*o)/o||0}}({x:b,y:_},O(s)):{x:b,y:_};return b=P.x,_=P.y,p?Object.assign({},k,((D={})[C]=A?"0":"",D[x]=E?"0":"",D.transform=(L.devicePixelRatio||1)<=1?"translate("+b+"px, "+_+"px)":"translate3d("+b+"px, "+_+"px, 0)",D)):Object.assign({},k,((r={})[C]=A?_+"px":"",r[x]=E?b+"px":"",r.transform="",r))}var Z={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(e){var t=e.state,n=e.options,i=n.gpuAcceleration,o=void 0===i||i,r=n.adaptive,s=void 0===r||r,a=n.roundOffsets,c=void 0===a||a,l={placement:j(t.placement),variation:$(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:o,isFixed:"fixed"===t.options.strategy};null!=t.modifiersData.popperOffsets&&(t.styles.popper=Object.assign({},t.styles.popper,X(Object.assign({},l,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:s,roundOffsets:c})))),null!=t.modifiersData.arrow&&(t.styles.arrow=Object.assign({},t.styles.arrow,X(Object.assign({},l,{offsets:t.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:c})))),t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-placement":t.placement})},data:{}},ee={passive:!0};var te={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(e){var t=e.state,n=e.instance,i=e.options,o=i.scroll,r=void 0===o||o,s=i.resize,a=void 0===s||s,c=O(t.elements.popper),l=[].concat(t.scrollParents.reference,t.scrollParents.popper);return r&&l.forEach((function(e){e.addEventListener("scroll",n.update,ee)})),a&&c.addEventListener("resize",n.update,ee),function(){r&&l.forEach((function(e){e.removeEventListener("scroll",n.update,ee)})),a&&c.removeEventListener("resize",n.update,ee)}},data:{}},ne={left:"right",right:"left",bottom:"top",top:"bottom"};function ie(e){return e.replace(/left|right|bottom|top/g,(function(e){return ne[e]}))}var oe={start:"end",end:"start"};function re(e){return e.replace(/start|end/g,(function(e){return oe[e]}))}function se(e){var t=O(e);return{scrollLeft:t.pageXOffset,scrollTop:t.pageYOffset}}function ae(e){return I(R(e)).left+se(e).scrollLeft}function ce(e){var t=H(e),n=t.overflow,i=t.overflowX,o=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+i)}function le(e){return["html","body","#document"].indexOf(x(e))>=0?e.ownerDocument.body:L(e)&&ce(e)?e:le(z(e))}function ue(e,t){var n;void 0===t&&(t=[]);var i=le(e),o=i===(null==(n=e.ownerDocument)?void 0:n.body),r=O(i),s=o?[r].concat(r.visualViewport||[],ce(i)?i:[]):i,a=t.concat(s);return o?a:a.concat(ue(z(s)))}function fe(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function de(e,t,n){return t===l?fe(function(e,t){var n=O(e),i=R(e),o=n.visualViewport,r=i.clientWidth,s=i.clientHeight,a=0,c=0;if(o){r=o.width,s=o.height;var l=N();(l||!l&&"fixed"===t)&&(a=o.offsetLeft,c=o.offsetTop)}return{width:r,height:s,x:a+ae(e),y:c}}(e,n)):C(t)?function(e,t){var n=I(e,!1,"fixed"===t);return n.top=n.top+e.clientTop,n.left=n.left+e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}(t,n):fe(function(e){var t,n=R(e),i=se(e),o=null==(t=e.ownerDocument)?void 0:t.body,r=D(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=D(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),a=-i.scrollLeft+ae(e),c=-i.scrollTop;return"rtl"===H(o||n).direction&&(a+=D(n.clientWidth,o?o.clientWidth:0)-r),{width:r,height:s,x:a,y:c}}(R(e)))}function pe(e,t,n,i){var o="clippingParents"===t?function(e){var t=ue(z(e)),n=["absolute","fixed"].indexOf(H(e).position)>=0&&L(e)?V(e):e;return C(n)?t.filter((function(e){return C(e)&&F(e,n)&&"body"!==x(e)})):[]}(e):[].concat(t),r=[].concat(o,[n]),s=r[0],a=r.reduce((function(t,n){var o=de(e,n,i);return t.top=D(o.top,t.top),t.right=k(o.right,t.right),t.bottom=k(o.bottom,t.bottom),t.left=D(o.left,t.left),t}),de(e,s,i));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function he(o){var r,c=o.reference,l=o.element,u=o.placement,f=u?j(u):null,d=u?$(u):null,p=c.x+c.width/2-l.width/2,h=c.y+c.height/2-l.height/2;switch(f){case e:r={x:p,y:c.y-l.height};break;case t:r={x:p,y:c.y+c.height};break;case n:r={x:c.x+c.width,y:h};break;case i:r={x:c.x-l.width,y:h};break;default:r={x:c.x,y:c.y}}var m=f?U(f):null;if(null!=m){var g="y"===m?"height":"width";switch(d){case s:r[m]=r[m]-(c[g]/2-l[g]/2);break;case a:r[m]=r[m]+(c[g]/2-l[g]/2)}}return r}function me(i,o){void 0===o&&(o={});var s=o,a=s.placement,d=void 0===a?i.placement:a,p=s.strategy,h=void 0===p?i.strategy:p,m=s.boundary,g=void 0===m?c:m,v=s.rootBoundary,b=void 0===v?l:v,y=s.elementContext,_=void 0===y?u:y,w=s.altBoundary,E=void 0!==w&&w,A=s.padding,x=void 0===A?0:A,O=K("number"!=typeof x?x:Q(x,r)),L=_===u?f:u,T=i.rects.popper,S=i.elements[E?L:_],j=pe(C(S)?S:S.contextElement||R(i.elements.popper),g,b,h),D=I(i.elements.reference),k=he({reference:D,element:T,strategy:"absolute",placement:d}),M=fe(Object.assign({},T,k)),P=_===u?M:D,N={top:j.top-P.top+O.top,bottom:P.bottom-j.bottom+O.bottom,left:j.left-P.left+O.left,right:P.right-j.right+O.right},B=i.modifiersData.offset;if(_===u&&B){var F=B[d];Object.keys(N).forEach((function(i){var o=[n,t].indexOf(i)>=0?1:-1,r=[e,t].indexOf(i)>=0?"y":"x";N[i]+=F[r]*o}))}return N}function ge(e,t){void 0===t&&(t={});var n=t,i=n.placement,o=n.boundary,s=n.rootBoundary,a=n.padding,c=n.flipVariations,l=n.allowedAutoPlacements,u=void 0===l?p:l,f=$(i),h=f?c?d:d.filter((function(e){return $(e)===f})):r,m=h.filter((function(e){return u.indexOf(e)>=0}));0===m.length&&(m=h);var g=m.reduce((function(t,n){return t[n]=me(e,{placement:n,boundary:o,rootBoundary:s,padding:a})[j(n)],t}),{});return Object.keys(g).sort((function(e,t){return g[e]-g[t]}))}var ve={name:"flip",enabled:!0,phase:"main",fn:function(r){var a=r.state,c=r.options,l=r.name;if(!a.modifiersData[l]._skip){for(var u=c.mainAxis,f=void 0===u||u,d=c.altAxis,p=void 0===d||d,h=c.fallbackPlacements,m=c.padding,g=c.boundary,v=c.rootBoundary,b=c.altBoundary,y=c.flipVariations,_=void 0===y||y,w=c.allowedAutoPlacements,E=a.options.placement,A=j(E),x=h||(A===E||!_?[ie(E)]:function(e){if(j(e)===o)return[];var t=ie(e);return[re(e),t,re(t)]}(E)),O=[E].concat(x).reduce((function(e,t){return e.concat(j(t)===o?ge(a,{placement:t,boundary:g,rootBoundary:v,padding:m,flipVariations:_,allowedAutoPlacements:w}):t)}),[]),C=a.rects.reference,L=a.rects.popper,T=new Map,S=!0,D=O[0],k=0;k=0,B=I?"width":"height",F=me(a,{placement:M,boundary:g,rootBoundary:v,altBoundary:b,padding:m}),H=I?N?n:i:N?t:e;C[B]>L[B]&&(H=ie(H));var q=ie(H),R=[];if(f&&R.push(F[P]<=0),p&&R.push(F[H]<=0,F[q]<=0),R.every((function(e){return e}))){D=M,S=!1;break}T.set(M,R)}if(S)for(var z=function(e){var t=O.find((function(t){var n=T.get(t);if(n)return n.slice(0,e).every((function(e){return e}))}));if(t)return D=t,"break"},W=_?3:1;W>0;W--){if("break"===z(W))break}a.placement!==D&&(a.modifiersData[l]._skip=!0,a.placement=D,a.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function be(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function ye(o){return[e,n,t,i].some((function(e){return o[e]>=0}))}var _e={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(e){var t=e.state,n=e.name,i=t.rects.reference,o=t.rects.popper,r=t.modifiersData.preventOverflow,s=me(t,{elementContext:"reference"}),a=me(t,{altBoundary:!0}),c=be(s,i),l=be(a,o,r),u=ye(c),f=ye(l);t.modifiersData[n]={referenceClippingOffsets:c,popperEscapeOffsets:l,isReferenceHidden:u,hasPopperEscaped:f},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":u,"data-popper-escaped":f})}};var we={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var o=t.state,r=t.options,s=t.name,a=r.offset,c=void 0===a?[0,0]:a,l=p.reduce((function(t,r){return t[r]=function(t,o,r){var s=j(t),a=[i,e].indexOf(s)>=0?-1:1,c="function"==typeof r?r(Object.assign({},o,{placement:t})):r,l=c[0],u=c[1];return l=l||0,u=(u||0)*a,[i,n].indexOf(s)>=0?{x:u,y:l}:{x:l,y:u}}(r,o.rects,c),t}),{}),u=l[o.placement],f=u.x,d=u.y;null!=o.modifiersData.popperOffsets&&(o.modifiersData.popperOffsets.x+=f,o.modifiersData.popperOffsets.y+=d),o.modifiersData[s]=l}};var Ee={name:"popperOffsets",enabled:!0,phase:"read",fn:function(e){var t=e.state,n=e.name;t.modifiersData[n]=he({reference:t.rects.reference,element:t.rects.popper,strategy:"absolute",placement:t.placement})},data:{}};var Ae={name:"preventOverflow",enabled:!0,phase:"main",fn:function(o){var r=o.state,a=o.options,c=o.name,l=a.mainAxis,u=void 0===l||l,f=a.altAxis,d=void 0!==f&&f,p=a.boundary,h=a.rootBoundary,m=a.altBoundary,g=a.padding,v=a.tether,b=void 0===v||v,y=a.tetherOffset,_=void 0===y?0:y,w=me(r,{boundary:p,rootBoundary:h,padding:g,altBoundary:m}),E=j(r.placement),A=$(r.placement),x=!A,O=U(E),C="x"===O?"y":"x",L=r.modifiersData.popperOffsets,T=r.rects.reference,S=r.rects.popper,M="function"==typeof _?_(Object.assign({},r.rects,{placement:r.placement})):_,P="number"==typeof M?{mainAxis:M,altAxis:M}:Object.assign({mainAxis:0,altAxis:0},M),N=r.modifiersData.offset?r.modifiersData.offset[r.placement]:null,I={x:0,y:0};if(L){if(u){var F,H="y"===O?e:i,q="y"===O?t:n,R="y"===O?"height":"width",z=L[O],W=z+w[H],K=z-w[q],Q=b?-S[R]/2:0,G=A===s?T[R]:S[R],J=A===s?-S[R]:-T[R],X=r.elements.arrow,Z=b&&X?B(X):{width:0,height:0},ee=r.modifiersData["arrow#persistent"]?r.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},te=ee[H],ne=ee[q],ie=Y(0,T[R],Z[R]),oe=x?T[R]/2-Q-ie-te-P.mainAxis:G-ie-te-P.mainAxis,re=x?-T[R]/2+Q+ie+ne+P.mainAxis:J+ie+ne+P.mainAxis,se=r.elements.arrow&&V(r.elements.arrow),ae=se?"y"===O?se.clientTop||0:se.clientLeft||0:0,ce=null!=(F=null==N?void 0:N[O])?F:0,le=z+re-ce,ue=Y(b?k(W,z+oe-ce-ae):W,z,b?D(K,le):K);L[O]=ue,I[O]=ue-z}if(d){var fe,de="x"===O?e:i,pe="x"===O?t:n,he=L[C],ge="y"===C?"height":"width",ve=he+w[de],be=he-w[pe],ye=-1!==[e,i].indexOf(E),_e=null!=(fe=null==N?void 0:N[C])?fe:0,we=ye?ve:he-T[ge]-S[ge]-_e+P.altAxis,Ee=ye?he+T[ge]+S[ge]-_e-P.altAxis:be,Ae=b&&ye?function(e,t,n){var i=Y(e,t,n);return i>n?n:i}(we,he,Ee):Y(b?we:ve,he,b?Ee:be);L[C]=Ae,I[C]=Ae-he}r.modifiersData[c]=I}},requiresIfExists:["offset"]};function xe(e,t,n){void 0===n&&(n=!1);var i,o,r=L(t),s=L(t)&&function(e){var t=e.getBoundingClientRect(),n=M(t.width)/e.offsetWidth||1,i=M(t.height)/e.offsetHeight||1;return 1!==n||1!==i}(t),a=R(t),c=I(e,s,n),l={scrollLeft:0,scrollTop:0},u={x:0,y:0};return(r||!r&&!n)&&(("body"!==x(t)||ce(a))&&(l=(i=t)!==O(i)&&L(i)?{scrollLeft:(o=i).scrollLeft,scrollTop:o.scrollTop}:se(i)),L(t)?((u=I(t,!0)).x+=t.clientLeft,u.y+=t.clientTop):a&&(u.x=ae(a))),{x:c.left+l.scrollLeft-u.x,y:c.top+l.scrollTop-u.y,width:c.width,height:c.height}}function Oe(e){var t=new Map,n=new Set,i=[];function o(e){n.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach((function(e){if(!n.has(e)){var i=t.get(e);i&&o(i)}})),i.push(e)}return e.forEach((function(e){t.set(e.name,e)})),e.forEach((function(e){n.has(e.name)||o(e)})),i}var Ce={placement:"bottom",modifiers:[],strategy:"absolute"};function Le(){for(var e=arguments.length,t=new Array(e),n=0;nMe.has(e)&&Me.get(e).get(t)||null,remove(e,t){if(!Me.has(e))return;const n=Me.get(e);n.delete(t),0===n.size&&Me.delete(e)}};const Ne="transitionend",Ie=e=>(e&&window.CSS&&window.CSS.escape&&(e=e.replace(/#([^\s"#']+)/g,((e,t)=>"#".concat(CSS.escape(t))))),e),Be=e=>!(!e||"object"!=typeof e)&&(void 0!==e.jquery&&(e=e[0]),void 0!==e.nodeType),Fe=e=>Be(e)?e.jquery?e[0]:e:"string"==typeof e&&e.length>0?document.querySelector(Ie(e)):null,He=e=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof e.getRootNode){const t=e.getRootNode();return t instanceof ShadowRoot?t:null}return e instanceof ShadowRoot?e:e.parentNode?He(e.parentNode):null},qe=()=>{},Re=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,ze=[],We=()=>"rtl"===document.documentElement.dir,Ve=e=>{var t;t=()=>{const t=Re();if(t){const n=e.NAME,i=t.fn[n];t.fn[n]=e.jQueryInterface,t.fn[n].Constructor=e,t.fn[n].noConflict=()=>(t.fn[n]=i,e.jQueryInterface)}},"loading"===document.readyState?(ze.length||document.addEventListener("DOMContentLoaded",(()=>{for(const e of ze)e()})),ze.push(t)):t()},Ue=function(e){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e;return"function"==typeof e?e(...arguments.length>1&&void 0!==arguments[1]?arguments[1]:[]):t},Ye=function(e,t){if(!(!(arguments.length>2&&void 0!==arguments[2])||arguments[2]))return void Ue(e);const n=(e=>{if(!e)return 0;let{transitionDuration:t,transitionDelay:n}=window.getComputedStyle(e);const i=Number.parseFloat(t),o=Number.parseFloat(n);return i||o?(t=t.split(",")[0],n=n.split(",")[0],1e3*(Number.parseFloat(t)+Number.parseFloat(n))):0})(t)+5;let i=!1;const o=n=>{let{target:r}=n;r===t&&(i=!0,t.removeEventListener(Ne,o),Ue(e))};t.addEventListener(Ne,o),setTimeout((()=>{i||t.dispatchEvent(new Event(Ne))}),n)},Ke=/[^.]*(?=\..*)\.|.*/,Qe=/\..*/,Ge=/::\d+$/,$e={};let Je=1;const Xe={mouseenter:"mouseover",mouseleave:"mouseout"},Ze=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function et(e,t){return t&&"".concat(t,"::").concat(Je++)||e.uidEvent||Je++}function tt(e){const t=et(e);return e.uidEvent=t,$e[t]=$e[t]||{},$e[t]}function nt(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;return Object.values(e).find((e=>e.callable===t&&e.delegationSelector===n))}function it(e,t,n){const i="string"==typeof t,o=i?n:t||n;let r=at(e);return Ze.has(r)||(r=e),[i,o,r]}function ot(e,t,n,i,o){if("string"!=typeof t||!e)return;let[r,s,a]=it(t,n,i);if(t in Xe){const e=e=>function(t){if(!t.relatedTarget||t.relatedTarget!==t.delegateTarget&&!t.delegateTarget.contains(t.relatedTarget))return e.call(this,t)};s=e(s)}const c=tt(e),l=c[a]||(c[a]={}),u=nt(l,s,r?n:null);if(u)return void(u.oneOff=u.oneOff&&o);const f=et(s,t.replace(Ke,"")),d=r?function(e,t,n){return function i(o){const r=e.querySelectorAll(t);for(let{target:s}=o;s&&s!==this;s=s.parentNode)for(const a of r)if(a===s)return lt(o,{delegateTarget:s}),i.oneOff&&ct.off(e,o.type,t,n),n.apply(s,[o])}}(e,n,s):function(e,t){return function n(i){return lt(i,{delegateTarget:e}),n.oneOff&&ct.off(e,i.type,t),t.apply(e,[i])}}(e,s);d.delegationSelector=r?n:null,d.callable=s,d.oneOff=o,d.uidEvent=f,l[f]=d,e.addEventListener(a,d,r)}function rt(e,t,n,i,o){const r=nt(t[n],i,o);r&&(e.removeEventListener(n,r,Boolean(o)),delete t[n][r.uidEvent])}function st(e,t,n,i){const o=t[n]||{};for(const[r,s]of Object.entries(o))r.includes(i)&&rt(e,t,n,s.callable,s.delegationSelector)}function at(e){return e=e.replace(Qe,""),Xe[e]||e}const ct={on(e,t,n,i){ot(e,t,n,i,!1)},one(e,t,n,i){ot(e,t,n,i,!0)},off(e,t,n,i){if("string"!=typeof t||!e)return;const[o,r,s]=it(t,n,i),a=s!==t,c=tt(e),l=c[s]||{},u=t.startsWith(".");if(void 0===r){if(u)for(const n of Object.keys(c))st(e,c,n,t.slice(1));for(const[n,i]of Object.entries(l)){const o=n.replace(Ge,"");a&&!t.includes(o)||rt(e,c,s,i.callable,i.delegationSelector)}}else{if(!Object.keys(l).length)return;rt(e,c,s,r,o?n:null)}},trigger(e,t,n){if("string"!=typeof t||!e)return null;const i=Re();let o=null,r=!0,s=!0,a=!1;t!==at(t)&&i&&(o=i.Event(t,n),i(e).trigger(o),r=!o.isPropagationStopped(),s=!o.isImmediatePropagationStopped(),a=o.isDefaultPrevented());const c=lt(new Event(t,{bubbles:r,cancelable:!0}),n);return a&&c.preventDefault(),s&&e.dispatchEvent(c),c.defaultPrevented&&o&&o.preventDefault(),c}};function lt(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(const[n,i]of Object.entries(t))try{e[n]=i}catch{Object.defineProperty(e,n,{configurable:!0,get:()=>i})}return e}function ut(e){if("true"===e)return!0;if("false"===e)return!1;if(e===Number(e).toString())return Number(e);if(""===e||"null"===e)return null;if("string"!=typeof e)return e;try{return JSON.parse(decodeURIComponent(e))}catch{return e}}function ft(e){return e.replace(/[A-Z]/g,(e=>"-".concat(e.toLowerCase())))}const dt={setDataAttribute(e,t,n){e.setAttribute("data-bs-".concat(ft(t)),n)},removeDataAttribute(e,t){e.removeAttribute("data-bs-".concat(ft(t)))},getDataAttributes(e){if(!e)return{};const t={},n=Object.keys(e.dataset).filter((e=>e.startsWith("bs")&&!e.startsWith("bsConfig")));for(const i of n){let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),t[n]=ut(e.dataset[i])}return t},getDataAttribute:(e,t)=>ut(e.getAttribute("data-bs-".concat(ft(t))))};class pt{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(e){return e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e}_mergeConfigObj(e,t){const n=Be(t)?dt.getDataAttribute(t,"config"):{};return{...this.constructor.Default,..."object"==typeof n?n:{},...Be(t)?dt.getDataAttributes(t):{},..."object"==typeof e?e:{}}}_typeCheckConfig(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.constructor.DefaultType;for(const[i,o]of Object.entries(t)){const t=e[i],r=Be(t)?"element":null==(n=t)?"".concat(n):Object.prototype.toString.call(n).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(o).test(r))throw new TypeError("".concat(this.constructor.NAME.toUpperCase(),': Option "').concat(i,'" provided type "').concat(r,'" but expected type "').concat(o,'".'))}var n}}class ht extends pt{constructor(e,t){super(),(e=Fe(e))&&(this._element=e,this._config=this._getConfig(t),Pe.set(this._element,this.constructor.DATA_KEY,this))}dispose(){Pe.remove(this._element,this.constructor.DATA_KEY),ct.off(this._element,this.constructor.EVENT_KEY);for(const e of Object.getOwnPropertyNames(this))this[e]=null}_queueCallback(e,t){Ye(e,t,!(arguments.length>2&&void 0!==arguments[2])||arguments[2])}_getConfig(e){return e=this._mergeConfigObj(e,this._element),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}static getInstance(e){return Pe.get(Fe(e),this.DATA_KEY)}static getOrCreateInstance(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return this.getInstance(e)||new this(e,"object"==typeof t?t:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return"bs.".concat(this.NAME)}static get EVENT_KEY(){return".".concat(this.DATA_KEY)}static eventName(e){return"".concat(e).concat(this.EVENT_KEY)}}const mt={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},gt=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),vt=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,bt=(e,t)=>{const n=e.nodeName.toLowerCase();return t.includes(n)?!gt.has(n)||Boolean(vt.test(e.nodeValue)):t.filter((e=>e instanceof RegExp)).some((e=>e.test(n)))};const yt=e=>{let t=e.getAttribute("data-bs-target");if(!t||"#"===t){let n=e.getAttribute("href");if(!n||!n.includes("#")&&!n.startsWith("."))return null;n.includes("#")&&!n.startsWith("#")&&(n="#".concat(n.split("#")[1])),t=n&&"#"!==n?n.trim():null}return t?t.split(",").map((e=>Ie(e))).join(","):null},_t={find(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return[].concat(...Element.prototype.querySelectorAll.call(t,e))},findOne(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return Element.prototype.querySelector.call(t,e)},children:(e,t)=>[].concat(...e.children).filter((e=>e.matches(t))),parents(e,t){const n=[];let i=e.parentNode.closest(t);for(;i;)n.push(i),i=i.parentNode.closest(t);return n},prev(e,t){let n=e.previousElementSibling;for(;n;){if(n.matches(t))return[n];n=n.previousElementSibling}return[]},next(e,t){let n=e.nextElementSibling;for(;n;){if(n.matches(t))return[n];n=n.nextElementSibling}return[]},focusableChildren(e){const t=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((e=>"".concat(e,':not([tabindex^="-"])'))).join(",");return this.find(t,e).filter((e=>!(e=>!e||e.nodeType!==Node.ELEMENT_NODE||!!e.classList.contains("disabled")||(void 0!==e.disabled?e.disabled:e.hasAttribute("disabled")&&"false"!==e.getAttribute("disabled")))(e)&&(e=>{if(!Be(e)||0===e.getClientRects().length)return!1;const t="visible"===getComputedStyle(e).getPropertyValue("visibility"),n=e.closest("details:not([open])");if(!n)return t;if(n!==e){const t=e.closest("summary");if(t&&t.parentNode!==n)return!1;if(null===t)return!1}return t})(e)))},getSelectorFromElement(e){const t=yt(e);return t&&_t.findOne(t)?t:null},getElementFromSelector(e){const t=yt(e);return t?_t.findOne(t):null},getMultipleElementsFromSelector(e){const t=yt(e);return t?_t.find(t):[]}},wt={allowList:mt,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Et={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},At={entry:"(string|element|function|null)",selector:"(string|element)"};class xt extends pt{constructor(e){super(),this._config=this._getConfig(e)}static get Default(){return wt}static get DefaultType(){return Et}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((e=>this._resolvePossibleFunction(e))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(e){return this._checkContent(e),this._config.content={...this._config.content,...e},this}toHtml(){const e=document.createElement("div");e.innerHTML=this._maybeSanitize(this._config.template);for(const[t,n]of Object.entries(this._config.content))this._setContent(e,n,t);const t=e.children[0],n=this._resolvePossibleFunction(this._config.extraClass);return n&&t.classList.add(...n.split(" ")),t}_typeCheckConfig(e){super._typeCheckConfig(e),this._checkContent(e.content)}_checkContent(e){for(const[t,n]of Object.entries(e))super._typeCheckConfig({selector:t,entry:n},At)}_setContent(e,t,n){const i=_t.findOne(n,e);i&&((t=this._resolvePossibleFunction(t))?Be(t)?this._putElementInTemplate(Fe(t),i):this._config.html?i.innerHTML=this._maybeSanitize(t):i.textContent=t:i.remove())}_maybeSanitize(e){return this._config.sanitize?function(e,t,n){if(!e.length)return e;if(n&&"function"==typeof n)return n(e);const i=(new window.DOMParser).parseFromString(e,"text/html"),o=[].concat(...i.body.querySelectorAll("*"));for(const e of o){const n=e.nodeName.toLowerCase();if(!Object.keys(t).includes(n)){e.remove();continue}const i=[].concat(...e.attributes),o=[].concat(t["*"]||[],t[n]||[]);for(const t of i)bt(t,o)||e.removeAttribute(t.nodeName)}return i.body.innerHTML}(e,this._config.allowList,this._config.sanitizeFn):e}_resolvePossibleFunction(e){return Ue(e,[this])}_putElementInTemplate(e,t){if(this._config.html)return t.innerHTML="",void t.append(e);t.textContent=e.textContent}}const Ot=new Set(["sanitize","allowList","sanitizeFn"]),Ct="fade",Lt="show",Tt=".".concat("modal"),St="hide.bs.modal",jt="hover",Dt="focus",kt={AUTO:"auto",TOP:"top",RIGHT:We()?"left":"right",BOTTOM:"bottom",LEFT:We()?"right":"left"},Mt={allowList:mt,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Pt={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Nt extends ht{constructor(e,t){if(void 0===ke)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(e,t),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Mt}static get DefaultType(){return Pt}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),ct.off(this._element.closest(Tt),St,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const e=ct.trigger(this._element,this.constructor.eventName("show")),t=(He(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(e.defaultPrevented||!t)return;this._disposePopper();const n=this._getTipElement();this._element.setAttribute("aria-describedby",n.getAttribute("id"));const{container:i}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(i.append(n),ct.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(n),n.classList.add(Lt),"ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))ct.on(e,"mouseover",qe);this._queueCallback((()=>{ct.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(!this._isShown())return;if(ct.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented)return;if(this._getTipElement().classList.remove(Lt),"ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))ct.off(e,"mouseover",qe);this._activeTrigger.click=!1,this._activeTrigger[Dt]=!1,this._activeTrigger[jt]=!1,this._isHovered=null;this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),ct.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(e){const t=this._getTemplateFactory(e).toHtml();if(!t)return null;t.classList.remove(Ct,Lt),t.classList.add("bs-".concat(this.constructor.NAME,"-auto"));const n=(e=>{do{e+=Math.floor(1e6*Math.random())}while(document.getElementById(e));return e})(this.constructor.NAME).toString();return t.setAttribute("id",n),this._isAnimated()&&t.classList.add(Ct),t}setContent(e){this._newContent=e,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(e){return this._templateFactory?this._templateFactory.changeContent(e):this._templateFactory=new xt({...this._config,content:e,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(e){return this.constructor.getOrCreateInstance(e.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ct)}_isShown(){return this.tip&&this.tip.classList.contains(Lt)}_createPopper(e){const t=Ue(this._config.placement,[this,e,this._element]),n=kt[t.toUpperCase()];return De(this._element,e,this._getPopperConfig(n))}_getOffset(){const{offset:e}=this._config;return"string"==typeof e?e.split(",").map((e=>Number.parseInt(e,10))):"function"==typeof e?t=>e(t,this._element):e}_resolvePossibleFunction(e){return Ue(e,[this._element])}_getPopperConfig(e){const t={placement:e,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:".".concat(this.constructor.NAME,"-arrow")}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:e=>{this._getTipElement().setAttribute("data-popper-placement",e.state.placement)}}]};return{...t,...Ue(this._config.popperConfig,[t])}}_setListeners(){const e=this._config.trigger.split(" ");for(const t of e)if("click"===t)ct.on(this._element,this.constructor.eventName("click"),this._config.selector,(e=>{this._initializeOnDelegatedTarget(e).toggle()}));else if("manual"!==t){const e=t===jt?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),n=t===jt?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");ct.on(this._element,e,this._config.selector,(e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusin"===e.type?Dt:jt]=!0,t._enter()})),ct.on(this._element,n,this._config.selector,(e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusout"===e.type?Dt:jt]=t._element.contains(e.relatedTarget),t._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},ct.on(this._element.closest(Tt),St,this._hideModalHandler)}_fixTitle(){const e=this._element.getAttribute("title");e&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",e),this._element.setAttribute("data-bs-original-title",e),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(e,t){clearTimeout(this._timeout),this._timeout=setTimeout(e,t)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(e){const t=dt.getDataAttributes(this._element);for(const e of Object.keys(t))Ot.has(e)&&delete t[e];return e={...t,..."object"==typeof e&&e?e:{}},e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e.container=!1===e.container?document.body:Fe(e.container),"number"==typeof e.delay&&(e.delay={show:e.delay,hide:e.delay}),"number"==typeof e.title&&(e.title=e.title.toString()),"number"==typeof e.content&&(e.content=e.content.toString()),e}_getDelegateConfig(){const e={};for(const[t,n]of Object.entries(this._config))this.constructor.Default[t]!==n&&(e[t]=n);return e.selector=!1,e.trigger="manual",e}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(e){return this.each((function(){const t=Nt.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError('No method named "'.concat(e,'"'));t[e]()}}))}}Ve(Nt);const It=document.getElementById("mode-toggle");function Bt(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var i=n.call(e,t||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function Ft(e,t,n){return(t=Bt(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}const Ht="sidebar-display";class qt{static toggle(){!1===qt.isExpanded?document.body.setAttribute(Ht,""):document.body.removeAttribute(Ht),qt.isExpanded=!qt.isExpanded}}Ft(qt,"isExpanded",!1);const Rt=document.getElementById("sidebar-trigger"),zt=document.getElementById("search-trigger"),Wt=document.getElementById("search-cancel"),Vt=document.querySelectorAll("#main-wrapper>.container>.row"),Ut=document.getElementById("topbar-title"),Yt=document.getElementById("search"),Kt=document.getElementById("search-result-wrapper"),Qt=document.getElementById("search-results"),Gt=document.getElementById("search-input"),$t=document.getElementById("search-hints"),Jt="d-block",Xt="d-none",Zt="input-focus",en="d-flex";class tn{static on(){Rt.classList.add(Xt),Ut.classList.add(Xt),zt.classList.add(Xt),Yt.classList.add(en),Wt.classList.add(Jt)}static off(){Wt.classList.remove(Jt),Yt.classList.remove(en),Rt.classList.remove(Xt),Ut.classList.remove(Xt),zt.classList.remove(Xt)}}class nn{static on(){this.resultVisible||(Kt.classList.remove(Xt),Vt.forEach((e=>{e.classList.add(Xt)})),this.resultVisible=!0)}static off(){this.resultVisible&&(Qt.innerHTML="",$t.classList.contains(Xt)&&$t.classList.remove(Xt),Kt.classList.add(Xt),Vt.forEach((e=>{e.classList.remove(Xt)})),Gt.textContent="",this.resultVisible=!1)}}function on(){return Wt.classList.contains(Jt)}Ft(nn,"resultVisible",!1);const rn=".".concat("bs.collapse"),sn="show".concat(rn),an="shown".concat(rn),cn="hide".concat(rn),ln="hidden".concat(rn),un="click".concat(rn).concat(".data-api"),fn="show",dn="collapse",pn="collapsing",hn=":scope .".concat(dn," .").concat(dn),mn='[data-bs-toggle="collapse"]',gn={parent:null,toggle:!0},vn={parent:"(null|element)",toggle:"boolean"};class bn extends ht{constructor(e,t){super(e,t),this._isTransitioning=!1,this._triggerArray=[];const n=_t.find(mn);for(const e of n){const t=_t.getSelectorFromElement(e),n=_t.find(t).filter((e=>e===this._element));null!==t&&n.length&&this._triggerArray.push(e)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return gn}static get DefaultType(){return vn}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let e=[];if(this._config.parent&&(e=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((e=>e!==this._element)).map((e=>bn.getOrCreateInstance(e,{toggle:!1})))),e.length&&e[0]._isTransitioning)return;if(ct.trigger(this._element,sn).defaultPrevented)return;for(const t of e)t.hide();const t=this._getDimension();this._element.classList.remove(dn),this._element.classList.add(pn),this._element.style[t]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const n=t[0].toUpperCase()+t.slice(1),i="scroll".concat(n);this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn,fn),this._element.style[t]="",ct.trigger(this._element,an)}),this._element,!0),this._element.style[t]="".concat(this._element[i],"px")}hide(){if(this._isTransitioning||!this._isShown())return;if(ct.trigger(this._element,cn).defaultPrevented)return;const e=this._getDimension();this._element.style[e]="".concat(this._element.getBoundingClientRect()[e],"px"),this._element.offsetHeight,this._element.classList.add(pn),this._element.classList.remove(dn,fn);for(const e of this._triggerArray){const t=_t.getElementFromSelector(e);t&&!this._isShown(t)&&this._addAriaAndCollapsedClass([e],!1)}this._isTransitioning=!0;this._element.style[e]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn),ct.trigger(this._element,ln)}),this._element,!0)}_isShown(){return(arguments.length>0&&void 0!==arguments[0]?arguments[0]:this._element).classList.contains(fn)}_configAfterMerge(e){return e.toggle=Boolean(e.toggle),e.parent=Fe(e.parent),e}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const e=this._getFirstLevelChildren(mn);for(const t of e){const e=_t.getElementFromSelector(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}}_getFirstLevelChildren(e){const t=_t.find(hn,this._config.parent);return _t.find(e,this._config.parent).filter((e=>!t.includes(e)))}_addAriaAndCollapsedClass(e,t){if(e.length)for(const n of e)n.classList.toggle("collapsed",!t),n.setAttribute("aria-expanded",t)}static jQueryInterface(e){const t={};return"string"==typeof e&&/show|hide/.test(e)&&(t.toggle=!1),this.each((function(){const n=bn.getOrCreateInstance(this,t);if("string"==typeof e){if(void 0===n[e])throw new TypeError('No method named "'.concat(e,'"'));n[e]()}}))}}ct.on(document,un,mn,(function(e){("A"===e.target.tagName||e.delegateTarget&&"A"===e.delegateTarget.tagName)&&e.preventDefault();for(const e of _t.getMultipleElementsFromSelector(this))bn.getOrCreateInstance(e,{toggle:!1}).toggle()})),Ve(bn),document.getElementsByClassName("collapse");const yn=".code-header>button",_n="far fa-clipboard",wn="fas fa-check",En="timeout",An="data-title-succeed",xn="data-bs-original-title",On=2e3;function Cn(e){if(e.hasAttribute(En)){let t=e.getAttribute(En);if(Number(t)>Date.now())return!0}return!1}function Ln(e){e.setAttribute(En,Date.now()+On)}function Tn(e){e.removeAttribute(En)}function Sn(){const e=document.querySelectorAll(yn);if(0===e.length)return;const t=new ClipboardJS(yn,{target:e=>e.parentNode.nextElementSibling.querySelector("code .rouge-code")});[...e].map((e=>new Nt(e,{placement:"left"}))),t.on("success",(e=>{const t=e.trigger;(e.clearSelection(),Cn(t))||(t.children[0].setAttribute("class",wn),function(e){const t=e.getAttribute(An);e.setAttribute(xn,t),Nt.getInstance(e).show()}(t),Ln(t),setTimeout((()=>{!function(e){Nt.getInstance(e).hide(),e.removeAttribute(xn)}(t),function(e){e.children[0].setAttribute("class",_n)}(t),Tn(t)}),On))}))}const jn="data-src",Dn="data-lqip",kn={SHIMMER:"shimmer",BLUR:"blur"};function Mn(e){this.parentElement.classList.remove(e)}function Pn(){this.complete&&(this.hasAttribute(Dn)?Mn.call(this,kn.BLUR):Mn.call(this,kn.SHIMMER))}function Nn(){const e=this.getAttribute(jn);this.setAttribute("src",encodeURI(e)),this.removeAttribute(jn)}const In=document.documentElement,Bn=".popup:not(.dark)",Fn=".popup:not(.light)";let Hn=Bn;!function(){const e=document.querySelectorAll("article img");if(0===e.length)return;e.forEach((e=>{e.addEventListener("load",Pn)})),document.querySelectorAll('article img[loading="lazy"]').forEach((e=>{e.complete&&Mn.call(e,kn.SHIMMER)}));const t=document.querySelectorAll("article img[".concat(Dn,'="true"]'));t.length&&t.forEach((e=>{Nn.call(e)}))}(),function(){if(null===document.querySelector(".popup"))return;const e=!(null===document.querySelector(".popup.light")&&null===document.querySelector(".popup.dark"));(In.hasAttribute("data-mode")&&"dark"===In.getAttribute("data-mode")||!In.hasAttribute("data-mode")&&window.matchMedia("(prefers-color-scheme: dark)").matches)&&(Hn=Fn);let t=GLightbox({selector:"".concat(Hn)});if(e&&document.getElementById("mode-toggle")){let e=null;window.addEventListener("message",(n=>{n.source===window&&n.data&&n.data.direction===ModeToggle.ID&&function(e,t){Hn=Hn===Bn?Fn:Bn,null===t&&(t=GLightbox({selector:"".concat(Hn)})),[e,t]=[t,e]}(t,e)}))}}(),It&&It.addEventListener("click",(()=>{modeToggle.flipMode()})),document.getElementById("sidebar-trigger").addEventListener("click",qt.toggle),document.getElementById("mask").addEventListener("click",qt.toggle),zt.addEventListener("click",(()=>{tn.on(),nn.on(),Gt.focus()})),Wt.addEventListener("click",(()=>{tn.off(),nn.off()})),Gt.addEventListener("focus",(()=>{Yt.classList.add(Zt)})),Gt.addEventListener("focusout",(()=>{Yt.classList.remove(Zt)})),Gt.addEventListener("input",(()=>{""===Gt.value?on()?$t.classList.remove(Xt):nn.off():(nn.on(),on()&&$t.classList.add(Xt))})),Sn(),function(){const e=document.getElementById("copy-link");null!==e&&(e.addEventListener("click",(e=>{const t=e.target;Cn(t)||navigator.clipboard.writeText(window.location.href).then((()=>{const e=t.getAttribute(xn),n=t.getAttribute(An);t.setAttribute(xn,n),Nt.getInstance(t).show(),Ln(t),setTimeout((()=>{t.setAttribute(xn,e),Tn(t)}),On)}))})),e.addEventListener("mouseleave",(e=>{Nt.getInstance(e.target).hide()})))}(),function(){const e=document.getElementById("back-to-top");window.addEventListener("scroll",(()=>{window.scrollY>50?e.classList.add("show"):e.classList.remove("show")})),e.addEventListener("click",(()=>{window.scrollTo({top:0})}))}(),[...document.querySelectorAll('[data-bs-toggle="tooltip"]')].map((e=>new Nt(e)))}(); diff --git a/assets/js/dist/post.min.js b/assets/js/dist/post.min.js new file mode 100644 index 0000000..850391b --- /dev/null +++ b/assets/js/dist/post.min.js @@ -0,0 +1,4 @@ +/*! + * jekyll-theme-chirpy v7.1.1 | © 2019 Cotes Chung | MIT Licensed | https://github.com/cotes2020/jekyll-theme-chirpy/ + */ +!function(){"use strict";var t="top",e="bottom",n="right",i="left",o="auto",r=[t,e,n,i],s="start",a="end",c="clippingParents",l="viewport",u="popper",f="reference",d=r.reduce((function(t,e){return t.concat([e+"-"+s,e+"-"+a])}),[]),p=[].concat(r,[o]).reduce((function(t,e){return t.concat([e,e+"-"+s,e+"-"+a])}),[]),h="beforeRead",m="read",g="afterRead",b="beforeMain",v="main",y="afterMain",_="beforeWrite",w="write",E="afterWrite",A=[h,m,g,b,v,y,_,w,E];function x(t){return t?(t.nodeName||"").toLowerCase():null}function O(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function C(t){return t instanceof O(t).Element||t instanceof Element}function T(t){return t instanceof O(t).HTMLElement||t instanceof HTMLElement}function L(t){return"undefined"!=typeof ShadowRoot&&(t instanceof O(t).ShadowRoot||t instanceof ShadowRoot)}var S={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var n=e.styles[t]||{},i=e.attributes[t]||{},o=e.elements[t];T(o)&&x(o)&&(Object.assign(o.style,n),Object.keys(i).forEach((function(t){var e=i[t];!1===e?o.removeAttribute(t):o.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,n={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,n.popper),e.styles=n,e.elements.arrow&&Object.assign(e.elements.arrow.style,n.arrow),function(){Object.keys(e.elements).forEach((function(t){var i=e.elements[t],o=e.attributes[t]||{},r=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:n[t]).reduce((function(t,e){return t[e]="",t}),{});T(i)&&x(i)&&(Object.assign(i.style,r),Object.keys(o).forEach((function(t){i.removeAttribute(t)})))}))}},requires:["computeStyles"]};function j(t){return t.split("-")[0]}var D=Math.max,k=Math.min,M=Math.round;function P(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function N(){return!/^((?!chrome|android).)*safari/i.test(P())}function I(t,e,n){void 0===e&&(e=!1),void 0===n&&(n=!1);var i=t.getBoundingClientRect(),o=1,r=1;e&&T(t)&&(o=t.offsetWidth>0&&M(i.width)/t.offsetWidth||1,r=t.offsetHeight>0&&M(i.height)/t.offsetHeight||1);var s=(C(t)?O(t):window).visualViewport,a=!N()&&n,c=(i.left+(a&&s?s.offsetLeft:0))/o,l=(i.top+(a&&s?s.offsetTop:0))/r,u=i.width/o,f=i.height/r;return{width:u,height:f,top:l,right:c+u,bottom:l+f,left:c,x:c,y:l}}function F(t){var e=I(t),n=t.offsetWidth,i=t.offsetHeight;return Math.abs(e.width-n)<=1&&(n=e.width),Math.abs(e.height-i)<=1&&(i=e.height),{x:t.offsetLeft,y:t.offsetTop,width:n,height:i}}function B(t,e){var n=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(n&&L(n)){var i=e;do{if(i&&t.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function H(t){return O(t).getComputedStyle(t)}function q(t){return["table","td","th"].indexOf(x(t))>=0}function R(t){return((C(t)?t.ownerDocument:t.document)||window.document).documentElement}function z(t){return"html"===x(t)?t:t.assignedSlot||t.parentNode||(L(t)?t.host:null)||R(t)}function W(t){return T(t)&&"fixed"!==H(t).position?t.offsetParent:null}function V(t){for(var e=O(t),n=W(t);n&&q(n)&&"static"===H(n).position;)n=W(n);return n&&("html"===x(n)||"body"===x(n)&&"static"===H(n).position)?e:n||function(t){var e=/firefox/i.test(P());if(/Trident/i.test(P())&&T(t)&&"fixed"===H(t).position)return null;var n=z(t);for(L(n)&&(n=n.host);T(n)&&["html","body"].indexOf(x(n))<0;){var i=H(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||e&&"filter"===i.willChange||e&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(t)||e}function U(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Y(t,e,n){return D(t,k(e,n))}function K(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function Q(t,e){return e.reduce((function(e,n){return e[n]=t,e}),{})}var G={name:"arrow",enabled:!0,phase:"main",fn:function(o){var s,a=o.state,c=o.name,l=o.options,u=a.elements.arrow,f=a.modifiersData.popperOffsets,d=j(a.placement),p=U(d),h=[i,n].indexOf(d)>=0?"height":"width";if(u&&f){var m=function(t,e){return K("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:Q(t,r))}(l.padding,a),g=F(u),b="y"===p?t:i,v="y"===p?e:n,y=a.rects.reference[h]+a.rects.reference[p]-f[p]-a.rects.popper[h],_=f[p]-a.rects.reference[p],w=V(u),E=w?"y"===p?w.clientHeight||0:w.clientWidth||0:0,A=y/2-_/2,x=m[b],O=E-g[h]-m[v],C=E/2-g[h]/2+A,T=Y(x,C,O),L=p;a.modifiersData[c]=((s={})[L]=T,s.centerOffset=T-C,s)}},effect:function(t){var e=t.state,n=t.options.element,i=void 0===n?"[data-popper-arrow]":n;null!=i&&("string"!=typeof i||(i=e.elements.popper.querySelector(i)))&&B(e.elements.popper,i)&&(e.elements.arrow=i)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function $(t){return t.split("-")[1]}var J={top:"auto",right:"auto",bottom:"auto",left:"auto"};function X(o){var r,s=o.popper,c=o.popperRect,l=o.placement,u=o.variation,f=o.offsets,d=o.position,p=o.gpuAcceleration,h=o.adaptive,m=o.roundOffsets,g=o.isFixed,b=f.x,v=void 0===b?0:b,y=f.y,_=void 0===y?0:y,w="function"==typeof m?m({x:v,y:_}):{x:v,y:_};v=w.x,_=w.y;var E=f.hasOwnProperty("x"),A=f.hasOwnProperty("y"),x=i,C=t,T=window;if(h){var L=V(s),S="clientHeight",j="clientWidth";if(L===O(s)&&"static"!==H(L=R(s)).position&&"absolute"===d&&(S="scrollHeight",j="scrollWidth"),l===t||(l===i||l===n)&&u===a)C=e,_-=(g&&L===T&&T.visualViewport?T.visualViewport.height:L[S])-c.height,_*=p?1:-1;if(l===i||(l===t||l===e)&&u===a)x=n,v-=(g&&L===T&&T.visualViewport?T.visualViewport.width:L[j])-c.width,v*=p?1:-1}var D,k=Object.assign({position:d},h&&J),P=!0===m?function(t,e){var n=t.x,i=t.y,o=e.devicePixelRatio||1;return{x:M(n*o)/o||0,y:M(i*o)/o||0}}({x:v,y:_},O(s)):{x:v,y:_};return v=P.x,_=P.y,p?Object.assign({},k,((D={})[C]=A?"0":"",D[x]=E?"0":"",D.transform=(T.devicePixelRatio||1)<=1?"translate("+v+"px, "+_+"px)":"translate3d("+v+"px, "+_+"px, 0)",D)):Object.assign({},k,((r={})[C]=A?_+"px":"",r[x]=E?v+"px":"",r.transform="",r))}var Z={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,n=t.options,i=n.gpuAcceleration,o=void 0===i||i,r=n.adaptive,s=void 0===r||r,a=n.roundOffsets,c=void 0===a||a,l={placement:j(e.placement),variation:$(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:o,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,X(Object.assign({},l,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:s,roundOffsets:c})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,X(Object.assign({},l,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:c})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}},tt={passive:!0};var et={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,n=t.instance,i=t.options,o=i.scroll,r=void 0===o||o,s=i.resize,a=void 0===s||s,c=O(e.elements.popper),l=[].concat(e.scrollParents.reference,e.scrollParents.popper);return r&&l.forEach((function(t){t.addEventListener("scroll",n.update,tt)})),a&&c.addEventListener("resize",n.update,tt),function(){r&&l.forEach((function(t){t.removeEventListener("scroll",n.update,tt)})),a&&c.removeEventListener("resize",n.update,tt)}},data:{}},nt={left:"right",right:"left",bottom:"top",top:"bottom"};function it(t){return t.replace(/left|right|bottom|top/g,(function(t){return nt[t]}))}var ot={start:"end",end:"start"};function rt(t){return t.replace(/start|end/g,(function(t){return ot[t]}))}function st(t){var e=O(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function at(t){return I(R(t)).left+st(t).scrollLeft}function ct(t){var e=H(t),n=e.overflow,i=e.overflowX,o=e.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+i)}function lt(t){return["html","body","#document"].indexOf(x(t))>=0?t.ownerDocument.body:T(t)&&ct(t)?t:lt(z(t))}function ut(t,e){var n;void 0===e&&(e=[]);var i=lt(t),o=i===(null==(n=t.ownerDocument)?void 0:n.body),r=O(i),s=o?[r].concat(r.visualViewport||[],ct(i)?i:[]):i,a=e.concat(s);return o?a:a.concat(ut(z(s)))}function ft(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function dt(t,e,n){return e===l?ft(function(t,e){var n=O(t),i=R(t),o=n.visualViewport,r=i.clientWidth,s=i.clientHeight,a=0,c=0;if(o){r=o.width,s=o.height;var l=N();(l||!l&&"fixed"===e)&&(a=o.offsetLeft,c=o.offsetTop)}return{width:r,height:s,x:a+at(t),y:c}}(t,n)):C(e)?function(t,e){var n=I(t,!1,"fixed"===e);return n.top=n.top+t.clientTop,n.left=n.left+t.clientLeft,n.bottom=n.top+t.clientHeight,n.right=n.left+t.clientWidth,n.width=t.clientWidth,n.height=t.clientHeight,n.x=n.left,n.y=n.top,n}(e,n):ft(function(t){var e,n=R(t),i=st(t),o=null==(e=t.ownerDocument)?void 0:e.body,r=D(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=D(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),a=-i.scrollLeft+at(t),c=-i.scrollTop;return"rtl"===H(o||n).direction&&(a+=D(n.clientWidth,o?o.clientWidth:0)-r),{width:r,height:s,x:a,y:c}}(R(t)))}function pt(t,e,n,i){var o="clippingParents"===e?function(t){var e=ut(z(t)),n=["absolute","fixed"].indexOf(H(t).position)>=0&&T(t)?V(t):t;return C(n)?e.filter((function(t){return C(t)&&B(t,n)&&"body"!==x(t)})):[]}(t):[].concat(e),r=[].concat(o,[n]),s=r[0],a=r.reduce((function(e,n){var o=dt(t,n,i);return e.top=D(o.top,e.top),e.right=k(o.right,e.right),e.bottom=k(o.bottom,e.bottom),e.left=D(o.left,e.left),e}),dt(t,s,i));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function ht(o){var r,c=o.reference,l=o.element,u=o.placement,f=u?j(u):null,d=u?$(u):null,p=c.x+c.width/2-l.width/2,h=c.y+c.height/2-l.height/2;switch(f){case t:r={x:p,y:c.y-l.height};break;case e:r={x:p,y:c.y+c.height};break;case n:r={x:c.x+c.width,y:h};break;case i:r={x:c.x-l.width,y:h};break;default:r={x:c.x,y:c.y}}var m=f?U(f):null;if(null!=m){var g="y"===m?"height":"width";switch(d){case s:r[m]=r[m]-(c[g]/2-l[g]/2);break;case a:r[m]=r[m]+(c[g]/2-l[g]/2)}}return r}function mt(i,o){void 0===o&&(o={});var s=o,a=s.placement,d=void 0===a?i.placement:a,p=s.strategy,h=void 0===p?i.strategy:p,m=s.boundary,g=void 0===m?c:m,b=s.rootBoundary,v=void 0===b?l:b,y=s.elementContext,_=void 0===y?u:y,w=s.altBoundary,E=void 0!==w&&w,A=s.padding,x=void 0===A?0:A,O=K("number"!=typeof x?x:Q(x,r)),T=_===u?f:u,L=i.rects.popper,S=i.elements[E?T:_],j=pt(C(S)?S:S.contextElement||R(i.elements.popper),g,v,h),D=I(i.elements.reference),k=ht({reference:D,element:L,strategy:"absolute",placement:d}),M=ft(Object.assign({},L,k)),P=_===u?M:D,N={top:j.top-P.top+O.top,bottom:P.bottom-j.bottom+O.bottom,left:j.left-P.left+O.left,right:P.right-j.right+O.right},F=i.modifiersData.offset;if(_===u&&F){var B=F[d];Object.keys(N).forEach((function(i){var o=[n,e].indexOf(i)>=0?1:-1,r=[t,e].indexOf(i)>=0?"y":"x";N[i]+=B[r]*o}))}return N}function gt(t,e){void 0===e&&(e={});var n=e,i=n.placement,o=n.boundary,s=n.rootBoundary,a=n.padding,c=n.flipVariations,l=n.allowedAutoPlacements,u=void 0===l?p:l,f=$(i),h=f?c?d:d.filter((function(t){return $(t)===f})):r,m=h.filter((function(t){return u.indexOf(t)>=0}));0===m.length&&(m=h);var g=m.reduce((function(e,n){return e[n]=mt(t,{placement:n,boundary:o,rootBoundary:s,padding:a})[j(n)],e}),{});return Object.keys(g).sort((function(t,e){return g[t]-g[e]}))}var bt={name:"flip",enabled:!0,phase:"main",fn:function(r){var a=r.state,c=r.options,l=r.name;if(!a.modifiersData[l]._skip){for(var u=c.mainAxis,f=void 0===u||u,d=c.altAxis,p=void 0===d||d,h=c.fallbackPlacements,m=c.padding,g=c.boundary,b=c.rootBoundary,v=c.altBoundary,y=c.flipVariations,_=void 0===y||y,w=c.allowedAutoPlacements,E=a.options.placement,A=j(E),x=h||(A===E||!_?[it(E)]:function(t){if(j(t)===o)return[];var e=it(t);return[rt(t),e,rt(e)]}(E)),O=[E].concat(x).reduce((function(t,e){return t.concat(j(e)===o?gt(a,{placement:e,boundary:g,rootBoundary:b,padding:m,flipVariations:_,allowedAutoPlacements:w}):e)}),[]),C=a.rects.reference,T=a.rects.popper,L=new Map,S=!0,D=O[0],k=0;k=0,F=I?"width":"height",B=mt(a,{placement:M,boundary:g,rootBoundary:b,altBoundary:v,padding:m}),H=I?N?n:i:N?e:t;C[F]>T[F]&&(H=it(H));var q=it(H),R=[];if(f&&R.push(B[P]<=0),p&&R.push(B[H]<=0,B[q]<=0),R.every((function(t){return t}))){D=M,S=!1;break}L.set(M,R)}if(S)for(var z=function(t){var e=O.find((function(e){var n=L.get(e);if(n)return n.slice(0,t).every((function(t){return t}))}));if(e)return D=e,"break"},W=_?3:1;W>0;W--){if("break"===z(W))break}a.placement!==D&&(a.modifiersData[l]._skip=!0,a.placement=D,a.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function vt(t,e,n){return void 0===n&&(n={x:0,y:0}),{top:t.top-e.height-n.y,right:t.right-e.width+n.x,bottom:t.bottom-e.height+n.y,left:t.left-e.width-n.x}}function yt(o){return[t,n,e,i].some((function(t){return o[t]>=0}))}var _t={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,n=t.name,i=e.rects.reference,o=e.rects.popper,r=e.modifiersData.preventOverflow,s=mt(e,{elementContext:"reference"}),a=mt(e,{altBoundary:!0}),c=vt(s,i),l=vt(a,o,r),u=yt(c),f=yt(l);e.modifiersData[n]={referenceClippingOffsets:c,popperEscapeOffsets:l,isReferenceHidden:u,hasPopperEscaped:f},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":u,"data-popper-escaped":f})}};var wt={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(e){var o=e.state,r=e.options,s=e.name,a=r.offset,c=void 0===a?[0,0]:a,l=p.reduce((function(e,r){return e[r]=function(e,o,r){var s=j(e),a=[i,t].indexOf(s)>=0?-1:1,c="function"==typeof r?r(Object.assign({},o,{placement:e})):r,l=c[0],u=c[1];return l=l||0,u=(u||0)*a,[i,n].indexOf(s)>=0?{x:u,y:l}:{x:l,y:u}}(r,o.rects,c),e}),{}),u=l[o.placement],f=u.x,d=u.y;null!=o.modifiersData.popperOffsets&&(o.modifiersData.popperOffsets.x+=f,o.modifiersData.popperOffsets.y+=d),o.modifiersData[s]=l}};var Et={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,n=t.name;e.modifiersData[n]=ht({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}};var At={name:"preventOverflow",enabled:!0,phase:"main",fn:function(o){var r=o.state,a=o.options,c=o.name,l=a.mainAxis,u=void 0===l||l,f=a.altAxis,d=void 0!==f&&f,p=a.boundary,h=a.rootBoundary,m=a.altBoundary,g=a.padding,b=a.tether,v=void 0===b||b,y=a.tetherOffset,_=void 0===y?0:y,w=mt(r,{boundary:p,rootBoundary:h,padding:g,altBoundary:m}),E=j(r.placement),A=$(r.placement),x=!A,O=U(E),C="x"===O?"y":"x",T=r.modifiersData.popperOffsets,L=r.rects.reference,S=r.rects.popper,M="function"==typeof _?_(Object.assign({},r.rects,{placement:r.placement})):_,P="number"==typeof M?{mainAxis:M,altAxis:M}:Object.assign({mainAxis:0,altAxis:0},M),N=r.modifiersData.offset?r.modifiersData.offset[r.placement]:null,I={x:0,y:0};if(T){if(u){var B,H="y"===O?t:i,q="y"===O?e:n,R="y"===O?"height":"width",z=T[O],W=z+w[H],K=z-w[q],Q=v?-S[R]/2:0,G=A===s?L[R]:S[R],J=A===s?-S[R]:-L[R],X=r.elements.arrow,Z=v&&X?F(X):{width:0,height:0},tt=r.modifiersData["arrow#persistent"]?r.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},et=tt[H],nt=tt[q],it=Y(0,L[R],Z[R]),ot=x?L[R]/2-Q-it-et-P.mainAxis:G-it-et-P.mainAxis,rt=x?-L[R]/2+Q+it+nt+P.mainAxis:J+it+nt+P.mainAxis,st=r.elements.arrow&&V(r.elements.arrow),at=st?"y"===O?st.clientTop||0:st.clientLeft||0:0,ct=null!=(B=null==N?void 0:N[O])?B:0,lt=z+rt-ct,ut=Y(v?k(W,z+ot-ct-at):W,z,v?D(K,lt):K);T[O]=ut,I[O]=ut-z}if(d){var ft,dt="x"===O?t:i,pt="x"===O?e:n,ht=T[C],gt="y"===C?"height":"width",bt=ht+w[dt],vt=ht-w[pt],yt=-1!==[t,i].indexOf(E),_t=null!=(ft=null==N?void 0:N[C])?ft:0,wt=yt?bt:ht-L[gt]-S[gt]-_t+P.altAxis,Et=yt?ht+L[gt]+S[gt]-_t-P.altAxis:vt,At=v&&yt?function(t,e,n){var i=Y(t,e,n);return i>n?n:i}(wt,ht,Et):Y(v?wt:bt,ht,v?Et:vt);T[C]=At,I[C]=At-ht}r.modifiersData[c]=I}},requiresIfExists:["offset"]};function xt(t,e,n){void 0===n&&(n=!1);var i,o,r=T(e),s=T(e)&&function(t){var e=t.getBoundingClientRect(),n=M(e.width)/t.offsetWidth||1,i=M(e.height)/t.offsetHeight||1;return 1!==n||1!==i}(e),a=R(e),c=I(t,s,n),l={scrollLeft:0,scrollTop:0},u={x:0,y:0};return(r||!r&&!n)&&(("body"!==x(e)||ct(a))&&(l=(i=e)!==O(i)&&T(i)?{scrollLeft:(o=i).scrollLeft,scrollTop:o.scrollTop}:st(i)),T(e)?((u=I(e,!0)).x+=e.clientLeft,u.y+=e.clientTop):a&&(u.x=at(a))),{x:c.left+l.scrollLeft-u.x,y:c.top+l.scrollTop-u.y,width:c.width,height:c.height}}function Ot(t){var e=new Map,n=new Set,i=[];function o(t){n.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!n.has(t)){var i=e.get(t);i&&o(i)}})),i.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){n.has(t.name)||o(t)})),i}var Ct={placement:"bottom",modifiers:[],strategy:"absolute"};function Tt(){for(var t=arguments.length,e=new Array(t),n=0;nMt.has(t)&&Mt.get(t).get(e)||null,remove(t,e){if(!Mt.has(t))return;const n=Mt.get(t);n.delete(e),0===n.size&&Mt.delete(t)}};const Nt="transitionend",It=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>"#".concat(CSS.escape(e))))),t),Ft=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),Bt=t=>Ft(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(It(t)):null,Ht=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?Ht(t.parentNode):null},qt=()=>{},Rt=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,zt=[],Wt=()=>"rtl"===document.documentElement.dir,Vt=t=>{var e;e=()=>{const e=Rt();if(e){const n=t.NAME,i=e.fn[n];e.fn[n]=t.jQueryInterface,e.fn[n].Constructor=t,e.fn[n].noConflict=()=>(e.fn[n]=i,t.jQueryInterface)}},"loading"===document.readyState?(zt.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of zt)t()})),zt.push(e)):e()},Ut=function(t){let e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:t;return"function"==typeof t?t(...arguments.length>1&&void 0!==arguments[1]?arguments[1]:[]):e},Yt=function(t,e){if(!(!(arguments.length>2&&void 0!==arguments[2])||arguments[2]))return void Ut(t);const n=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:n}=window.getComputedStyle(t);const i=Number.parseFloat(e),o=Number.parseFloat(n);return i||o?(e=e.split(",")[0],n=n.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(n))):0})(e)+5;let i=!1;const o=n=>{let{target:r}=n;r===e&&(i=!0,e.removeEventListener(Nt,o),Ut(t))};e.addEventListener(Nt,o),setTimeout((()=>{i||e.dispatchEvent(new Event(Nt))}),n)},Kt=/[^.]*(?=\..*)\.|.*/,Qt=/\..*/,Gt=/::\d+$/,$t={};let Jt=1;const Xt={mouseenter:"mouseover",mouseleave:"mouseout"},Zt=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function te(t,e){return e&&"".concat(e,"::").concat(Jt++)||t.uidEvent||Jt++}function ee(t){const e=te(t);return t.uidEvent=e,$t[e]=$t[e]||{},$t[e]}function ne(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===n))}function ie(t,e,n){const i="string"==typeof e,o=i?n:e||n;let r=ae(t);return Zt.has(r)||(r=t),[i,o,r]}function oe(t,e,n,i,o){if("string"!=typeof e||!t)return;let[r,s,a]=ie(e,n,i);if(e in Xt){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};s=t(s)}const c=ee(t),l=c[a]||(c[a]={}),u=ne(l,s,r?n:null);if(u)return void(u.oneOff=u.oneOff&&o);const f=te(s,e.replace(Kt,"")),d=r?function(t,e,n){return function i(o){const r=t.querySelectorAll(e);for(let{target:s}=o;s&&s!==this;s=s.parentNode)for(const a of r)if(a===s)return le(o,{delegateTarget:s}),i.oneOff&&ce.off(t,o.type,e,n),n.apply(s,[o])}}(t,n,s):function(t,e){return function n(i){return le(i,{delegateTarget:t}),n.oneOff&&ce.off(t,i.type,e),e.apply(t,[i])}}(t,s);d.delegationSelector=r?n:null,d.callable=s,d.oneOff=o,d.uidEvent=f,l[f]=d,t.addEventListener(a,d,r)}function re(t,e,n,i,o){const r=ne(e[n],i,o);r&&(t.removeEventListener(n,r,Boolean(o)),delete e[n][r.uidEvent])}function se(t,e,n,i){const o=e[n]||{};for(const[r,s]of Object.entries(o))r.includes(i)&&re(t,e,n,s.callable,s.delegationSelector)}function ae(t){return t=t.replace(Qt,""),Xt[t]||t}const ce={on(t,e,n,i){oe(t,e,n,i,!1)},one(t,e,n,i){oe(t,e,n,i,!0)},off(t,e,n,i){if("string"!=typeof e||!t)return;const[o,r,s]=ie(e,n,i),a=s!==e,c=ee(t),l=c[s]||{},u=e.startsWith(".");if(void 0===r){if(u)for(const n of Object.keys(c))se(t,c,n,e.slice(1));for(const[n,i]of Object.entries(l)){const o=n.replace(Gt,"");a&&!e.includes(o)||re(t,c,s,i.callable,i.delegationSelector)}}else{if(!Object.keys(l).length)return;re(t,c,s,r,o?n:null)}},trigger(t,e,n){if("string"!=typeof e||!t)return null;const i=Rt();let o=null,r=!0,s=!0,a=!1;e!==ae(e)&&i&&(o=i.Event(e,n),i(t).trigger(o),r=!o.isPropagationStopped(),s=!o.isImmediatePropagationStopped(),a=o.isDefaultPrevented());const c=le(new Event(e,{bubbles:r,cancelable:!0}),n);return a&&c.preventDefault(),s&&t.dispatchEvent(c),c.defaultPrevented&&o&&o.preventDefault(),c}};function le(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(const[n,i]of Object.entries(e))try{t[n]=i}catch{Object.defineProperty(t,n,{configurable:!0,get:()=>i})}return t}function ue(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch{return t}}function fe(t){return t.replace(/[A-Z]/g,(t=>"-".concat(t.toLowerCase())))}const de={setDataAttribute(t,e,n){t.setAttribute("data-bs-".concat(fe(e)),n)},removeDataAttribute(t,e){t.removeAttribute("data-bs-".concat(fe(e)))},getDataAttributes(t){if(!t)return{};const e={},n=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const i of n){let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=ue(t.dataset[i])}return e},getDataAttribute:(t,e)=>ue(t.getAttribute("data-bs-".concat(fe(e))))};class pe{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const n=Ft(e)?de.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof n?n:{},...Ft(e)?de.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.constructor.DefaultType;for(const[i,o]of Object.entries(e)){const e=t[i],r=Ft(e)?"element":null==(n=e)?"".concat(n):Object.prototype.toString.call(n).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(o).test(r))throw new TypeError("".concat(this.constructor.NAME.toUpperCase(),': Option "').concat(i,'" provided type "').concat(r,'" but expected type "').concat(o,'".'))}var n}}class he extends pe{constructor(t,e){super(),(t=Bt(t))&&(this._element=t,this._config=this._getConfig(e),Pt.set(this._element,this.constructor.DATA_KEY,this))}dispose(){Pt.remove(this._element,this.constructor.DATA_KEY),ce.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e){Yt(t,e,!(arguments.length>2&&void 0!==arguments[2])||arguments[2])}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return Pt.get(Bt(t),this.DATA_KEY)}static getOrCreateInstance(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return"bs.".concat(this.NAME)}static get EVENT_KEY(){return".".concat(this.DATA_KEY)}static eventName(t){return"".concat(t).concat(this.EVENT_KEY)}}const me={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},ge=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),be=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,ve=(t,e)=>{const n=t.nodeName.toLowerCase();return e.includes(n)?!ge.has(n)||Boolean(be.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(n)))};const ye=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let n=t.getAttribute("href");if(!n||!n.includes("#")&&!n.startsWith("."))return null;n.includes("#")&&!n.startsWith("#")&&(n="#".concat(n.split("#")[1])),e=n&&"#"!==n?n.trim():null}return e?e.split(",").map((t=>It(t))).join(","):null},_e={find(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return[].concat(...Element.prototype.querySelectorAll.call(e,t))},findOne(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document.documentElement;return Element.prototype.querySelector.call(e,t)},children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const n=[];let i=t.parentNode.closest(e);for(;i;)n.push(i),i=i.parentNode.closest(e);return n},prev(t,e){let n=t.previousElementSibling;for(;n;){if(n.matches(e))return[n];n=n.previousElementSibling}return[]},next(t,e){let n=t.nextElementSibling;for(;n;){if(n.matches(e))return[n];n=n.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>"".concat(t,':not([tabindex^="-"])'))).join(",");return this.find(e,t).filter((t=>!(t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")))(t)&&(t=>{if(!Ft(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),n=t.closest("details:not([open])");if(!n)return e;if(n!==t){const e=t.closest("summary");if(e&&e.parentNode!==n)return!1;if(null===e)return!1}return e})(t)))},getSelectorFromElement(t){const e=ye(t);return e&&_e.findOne(e)?e:null},getElementFromSelector(t){const e=ye(t);return e?_e.findOne(e):null},getMultipleElementsFromSelector(t){const e=ye(t);return e?_e.find(e):[]}},we={allowList:me,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Ee={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Ae={entry:"(string|element|function|null)",selector:"(string|element)"};class xe extends pe{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return we}static get DefaultType(){return Ee}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,n]of Object.entries(this._config.content))this._setContent(t,n,e);const e=t.children[0],n=this._resolvePossibleFunction(this._config.extraClass);return n&&e.classList.add(...n.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,n]of Object.entries(t))super._typeCheckConfig({selector:e,entry:n},Ae)}_setContent(t,e,n){const i=_e.findOne(n,t);i&&((e=this._resolvePossibleFunction(e))?Ft(e)?this._putElementInTemplate(Bt(e),i):this._config.html?i.innerHTML=this._maybeSanitize(e):i.textContent=e:i.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,n){if(!t.length)return t;if(n&&"function"==typeof n)return n(t);const i=(new window.DOMParser).parseFromString(t,"text/html"),o=[].concat(...i.body.querySelectorAll("*"));for(const t of o){const n=t.nodeName.toLowerCase();if(!Object.keys(e).includes(n)){t.remove();continue}const i=[].concat(...t.attributes),o=[].concat(e["*"]||[],e[n]||[]);for(const e of i)ve(e,o)||t.removeAttribute(e.nodeName)}return i.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return Ut(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Oe=new Set(["sanitize","allowList","sanitizeFn"]),Ce="fade",Te="show",Le=".".concat("modal"),Se="hide.bs.modal",je="hover",De="focus",ke={AUTO:"auto",TOP:"top",RIGHT:Wt()?"left":"right",BOTTOM:"bottom",LEFT:Wt()?"right":"left"},Me={allowList:me,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Pe={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Ne extends he{constructor(t,e){if(void 0===kt)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Me}static get DefaultType(){return Pe}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),ce.off(this._element.closest(Le),Se,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=ce.trigger(this._element,this.constructor.eventName("show")),e=(Ht(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const n=this._getTipElement();this._element.setAttribute("aria-describedby",n.getAttribute("id"));const{container:i}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(i.append(n),ce.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(n),n.classList.add(Te),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))ce.on(t,"mouseover",qt);this._queueCallback((()=>{ce.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(!this._isShown())return;if(ce.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented)return;if(this._getTipElement().classList.remove(Te),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))ce.off(t,"mouseover",qt);this._activeTrigger.click=!1,this._activeTrigger[De]=!1,this._activeTrigger[je]=!1,this._isHovered=null;this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),ce.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ce,Te),e.classList.add("bs-".concat(this.constructor.NAME,"-auto"));const n=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",n),this._isAnimated()&&e.classList.add(Ce),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new xe({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ce)}_isShown(){return this.tip&&this.tip.classList.contains(Te)}_createPopper(t){const e=Ut(this._config.placement,[this,t,this._element]),n=ke[e.toUpperCase()];return Dt(this._element,t,this._getPopperConfig(n))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return Ut(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:".".concat(this.constructor.NAME,"-arrow")}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...Ut(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)ce.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===je?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),n=e===je?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");ce.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?De:je]=!0,e._enter()})),ce.on(this._element,n,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?De:je]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},ce.on(this._element.closest(Le),Se,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=de.getDataAttributes(this._element);for(const t of Object.keys(e))Oe.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:Bt(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,n]of Object.entries(this._config))this.constructor.Default[e]!==n&&(t[e]=n);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=Ne.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError('No method named "'.concat(t,'"'));e[t]()}}))}}Vt(Ne);const Ie=document.getElementById("mode-toggle");function Fe(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var i=n.call(t,e||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:e+""}function Be(t,e,n){return(e=Fe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}const He="sidebar-display";class qe{static toggle(){!1===qe.isExpanded?document.body.setAttribute(He,""):document.body.removeAttribute(He),qe.isExpanded=!qe.isExpanded}}Be(qe,"isExpanded",!1);const Re=document.getElementById("sidebar-trigger"),ze=document.getElementById("search-trigger"),We=document.getElementById("search-cancel"),Ve=document.querySelectorAll("#main-wrapper>.container>.row"),Ue=document.getElementById("topbar-title"),Ye=document.getElementById("search"),Ke=document.getElementById("search-result-wrapper"),Qe=document.getElementById("search-results"),Ge=document.getElementById("search-input"),$e=document.getElementById("search-hints"),Je="d-block",Xe="d-none",Ze="input-focus",tn="d-flex";class en{static on(){Re.classList.add(Xe),Ue.classList.add(Xe),ze.classList.add(Xe),Ye.classList.add(tn),We.classList.add(Je)}static off(){We.classList.remove(Je),Ye.classList.remove(tn),Re.classList.remove(Xe),Ue.classList.remove(Xe),ze.classList.remove(Xe)}}class nn{static on(){this.resultVisible||(Ke.classList.remove(Xe),Ve.forEach((t=>{t.classList.add(Xe)})),this.resultVisible=!0)}static off(){this.resultVisible&&(Qe.innerHTML="",$e.classList.contains(Xe)&&$e.classList.remove(Xe),Ke.classList.add(Xe),Ve.forEach((t=>{t.classList.remove(Xe)})),Ge.textContent="",this.resultVisible=!1)}}function on(){return We.classList.contains(Je)}Be(nn,"resultVisible",!1);const rn=".".concat("bs.collapse"),sn="show".concat(rn),an="shown".concat(rn),cn="hide".concat(rn),ln="hidden".concat(rn),un="click".concat(rn).concat(".data-api"),fn="show",dn="collapse",pn="collapsing",hn=":scope .".concat(dn," .").concat(dn),mn='[data-bs-toggle="collapse"]',gn={parent:null,toggle:!0},bn={parent:"(null|element)",toggle:"boolean"};class vn extends he{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const n=_e.find(mn);for(const t of n){const e=_e.getSelectorFromElement(t),n=_e.find(e).filter((t=>t===this._element));null!==e&&n.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return gn}static get DefaultType(){return bn}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>vn.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(ce.trigger(this._element,sn).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(dn),this._element.classList.add(pn),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const n=e[0].toUpperCase()+e.slice(1),i="scroll".concat(n);this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn,fn),this._element.style[e]="",ce.trigger(this._element,an)}),this._element,!0),this._element.style[e]="".concat(this._element[i],"px")}hide(){if(this._isTransitioning||!this._isShown())return;if(ce.trigger(this._element,cn).defaultPrevented)return;const t=this._getDimension();this._element.style[t]="".concat(this._element.getBoundingClientRect()[t],"px"),this._element.offsetHeight,this._element.classList.add(pn),this._element.classList.remove(dn,fn);for(const t of this._triggerArray){const e=_e.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0;this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(pn),this._element.classList.add(dn),ce.trigger(this._element,ln)}),this._element,!0)}_isShown(){return(arguments.length>0&&void 0!==arguments[0]?arguments[0]:this._element).classList.contains(fn)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=Bt(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(mn);for(const e of t){const t=_e.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=_e.find(hn,this._config.parent);return _e.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const n of t)n.classList.toggle("collapsed",!e),n.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const n=vn.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===n[t])throw new TypeError('No method named "'.concat(t,'"'));n[t]()}}))}}ce.on(document,un,mn,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of _e.getMultipleElementsFromSelector(this))vn.getOrCreateInstance(t,{toggle:!1}).toggle()})),Vt(vn),document.getElementsByClassName("collapse");const yn=".code-header>button",_n="far fa-clipboard",wn="fas fa-check",En="timeout",An="data-title-succeed",xn="data-bs-original-title",On=2e3;function Cn(t){if(t.hasAttribute(En)){let e=t.getAttribute(En);if(Number(e)>Date.now())return!0}return!1}function Tn(t){t.setAttribute(En,Date.now()+On)}function Ln(t){t.removeAttribute(En)}function Sn(){const t=document.querySelectorAll(yn);if(0===t.length)return;const e=new ClipboardJS(yn,{target:t=>t.parentNode.nextElementSibling.querySelector("code .rouge-code")});[...t].map((t=>new Ne(t,{placement:"left"}))),e.on("success",(t=>{const e=t.trigger;(t.clearSelection(),Cn(e))||(e.children[0].setAttribute("class",wn),function(t){const e=t.getAttribute(An);t.setAttribute(xn,e),Ne.getInstance(t).show()}(e),Tn(e),setTimeout((()=>{!function(t){Ne.getInstance(t).hide(),t.removeAttribute(xn)}(e),function(t){t.children[0].setAttribute("class",_n)}(e),Ln(e)}),On))}))}const jn="data-src",Dn="data-lqip",kn={SHIMMER:"shimmer",BLUR:"blur"};function Mn(t){this.parentElement.classList.remove(t)}function Pn(){this.complete&&(this.hasAttribute(Dn)?Mn.call(this,kn.BLUR):Mn.call(this,kn.SHIMMER))}function Nn(){const t=this.getAttribute(jn);this.setAttribute("src",encodeURI(t)),this.removeAttribute(jn)}const In=document.documentElement,Fn=".popup:not(.dark)",Bn=".popup:not(.light)";let Hn=Fn;class qn{static get attrTimestamp(){return"data-ts"}static get attrDateFormat(){return"data-df"}static get locale(){return document.documentElement.getAttribute("lang").substring(0,2)}static getTimestamp(t){return Number(t.getAttribute(this.attrTimestamp))}static getDateFormat(t){return t.getAttribute(this.attrDateFormat)}}!function(){const t=document.querySelectorAll("article img");if(0===t.length)return;t.forEach((t=>{t.addEventListener("load",Pn)})),document.querySelectorAll('article img[loading="lazy"]').forEach((t=>{t.complete&&Mn.call(t,kn.SHIMMER)}));const e=document.querySelectorAll("article img[".concat(Dn,'="true"]'));e.length&&e.forEach((t=>{Nn.call(t)}))}(),document.querySelector("main h2, main h3")&&(tocbot.init({tocSelector:"#toc",contentSelector:".content",ignoreSelector:"[data-toc-skip]",headingSelector:"h2, h3, h4",orderedList:!1,scrollSmooth:!1}),document.getElementById("toc-wrapper").classList.remove("d-none")),function(){if(null===document.querySelector(".popup"))return;const t=!(null===document.querySelector(".popup.light")&&null===document.querySelector(".popup.dark"));(In.hasAttribute("data-mode")&&"dark"===In.getAttribute("data-mode")||!In.hasAttribute("data-mode")&&window.matchMedia("(prefers-color-scheme: dark)").matches)&&(Hn=Bn);let e=GLightbox({selector:"".concat(Hn)});if(t&&document.getElementById("mode-toggle")){let t=null;window.addEventListener("message",(n=>{n.source===window&&n.data&&n.data.direction===ModeToggle.ID&&function(t,e){Hn=Hn===Fn?Bn:Fn,null===e&&(e=GLightbox({selector:"".concat(Hn)})),[t,e]=[e,t]}(e,t)}))}}(),Ie&&Ie.addEventListener("click",(()=>{modeToggle.flipMode()})),document.getElementById("sidebar-trigger").addEventListener("click",qe.toggle),document.getElementById("mask").addEventListener("click",qe.toggle),dayjs.locale(qn.locale),dayjs.extend(window.dayjs_plugin_localizedFormat),document.querySelectorAll("[".concat(qn.attrTimestamp,"]")).forEach((t=>{const e=dayjs.unix(qn.getTimestamp(t)),n=e.format(qn.getDateFormat(t));if(t.textContent=n,t.removeAttribute(qn.attrTimestamp),t.removeAttribute(qn.attrDateFormat),t.hasAttribute("data-bs-toggle")&&"tooltip"===t.getAttribute("data-bs-toggle")){const n=e.format("llll");t.setAttribute("data-bs-title",n)}})),Sn(),function(){const t=document.getElementById("copy-link");null!==t&&(t.addEventListener("click",(t=>{const e=t.target;Cn(e)||navigator.clipboard.writeText(window.location.href).then((()=>{const t=e.getAttribute(xn),n=e.getAttribute(An);e.setAttribute(xn,n),Ne.getInstance(e).show(),Tn(e),setTimeout((()=>{e.setAttribute(xn,t),Ln(e)}),On)}))})),t.addEventListener("mouseleave",(t=>{Ne.getInstance(t.target).hide()})))}(),ze.addEventListener("click",(()=>{en.on(),nn.on(),Ge.focus()})),We.addEventListener("click",(()=>{en.off(),nn.off()})),Ge.addEventListener("focus",(()=>{Ye.classList.add(Ze)})),Ge.addEventListener("focusout",(()=>{Ye.classList.remove(Ze)})),Ge.addEventListener("input",(()=>{""===Ge.value?on()?$e.classList.remove(Xe):nn.off():(nn.on(),on()&&$e.classList.add(Xe))})),function(){const t=document.getElementById("back-to-top");window.addEventListener("scroll",(()=>{window.scrollY>50?t.classList.add("show"):t.classList.remove("show")})),t.addEventListener("click",(()=>{window.scrollTo({top:0})}))}(),[...document.querySelectorAll('[data-bs-toggle="tooltip"]')].map((t=>new Ne(t)))}(); diff --git a/assets/js/dist/sw.min.js b/assets/js/dist/sw.min.js new file mode 100644 index 0000000..4a29833 --- /dev/null +++ b/assets/js/dist/sw.min.js @@ -0,0 +1,7 @@ +--- +permalink: /:basename +--- +/*! + * jekyll-theme-chirpy v7.1.1 | © 2019 Cotes Chung | MIT Licensed | https://github.com/cotes2020/jekyll-theme-chirpy/ + */ +!function(){"use strict";importScripts("./assets/js/data/swconf.js");const e=swconf.purge,t=swconf.interceptor;self.addEventListener("install",(t=>{e||t.waitUntil(caches.open(swconf.cacheName).then((e=>e.addAll(swconf.resources))))})),self.addEventListener("activate",(t=>{t.waitUntil(caches.keys().then((t=>Promise.all(t.map((t=>e||t!==swconf.cacheName?caches.delete(t):void 0))))))})),self.addEventListener("message",(e=>{"SKIP_WAITING"===e.data&&self.skipWaiting()})),self.addEventListener("fetch",(s=>{s.request.headers.has("range")||s.respondWith(caches.match(s.request).then((n=>n||fetch(s.request).then((n=>{const r=s.request.url;if(e||"GET"!==s.request.method||!function(e){const s=new URL(e),n=s.pathname;if(!s.protocol.startsWith("http"))return!1;for(const e of t.urlPrefixes)if(s.href.startsWith(e))return!1;for(const e of t.paths)if(n.startsWith(e))return!1;return!0}(r))return n;let a=n.clone();return caches.open(swconf.cacheName).then((e=>{e.put(s.request,a)})),n})))))}))}(); diff --git a/assets/robots.txt b/assets/robots.txt new file mode 100644 index 0000000..45c34e0 --- /dev/null +++ b/assets/robots.txt @@ -0,0 +1,10 @@ +--- +permalink: /robots.txt +# The robots rules +--- + +User-agent: * + +Disallow: /norobots/ + +Sitemap: {{ '/sitemap.xml' | absolute_url }} diff --git a/assets/screenshots/2020-12-08-extends-template/failed-check.png b/assets/screenshots/2020-12-08-extends-template/failed-check.png new file mode 100644 index 0000000..c82d6e3 Binary files /dev/null and b/assets/screenshots/2020-12-08-extends-template/failed-check.png differ diff --git a/assets/screenshots/2020-12-08-extends-template/failed-stage.png b/assets/screenshots/2020-12-08-extends-template/failed-stage.png new file mode 100644 index 0000000..a51baa9 Binary files /dev/null and b/assets/screenshots/2020-12-08-extends-template/failed-stage.png differ diff --git a/assets/screenshots/2020-12-08-extends-template/required-check.png b/assets/screenshots/2020-12-08-extends-template/required-check.png new file mode 100644 index 0000000..c146caf Binary files /dev/null and b/assets/screenshots/2020-12-08-extends-template/required-check.png differ diff --git a/assets/screenshots/2020-12-08-extends-template/successful-stage.png b/assets/screenshots/2020-12-08-extends-template/successful-stage.png new file mode 100644 index 0000000..cb9f87a Binary files /dev/null and b/assets/screenshots/2020-12-08-extends-template/successful-stage.png differ diff --git a/assets/screenshots/2020-12-16-github-codeql-pr/branch-protection-configuration.png b/assets/screenshots/2020-12-16-github-codeql-pr/branch-protection-configuration.png new file mode 100644 index 0000000..0a3431c Binary files /dev/null and b/assets/screenshots/2020-12-16-github-codeql-pr/branch-protection-configuration.png differ diff --git a/assets/screenshots/2020-12-16-github-codeql-pr/code-scanning-configuration.png b/assets/screenshots/2020-12-16-github-codeql-pr/code-scanning-configuration.png new file mode 100644 index 0000000..40d7a4f Binary files /dev/null and b/assets/screenshots/2020-12-16-github-codeql-pr/code-scanning-configuration.png differ diff --git a/assets/screenshots/2020-12-16-github-codeql-pr/pr-blocked.png b/assets/screenshots/2020-12-16-github-codeql-pr/pr-blocked.png new file mode 100644 index 0000000..95dc237 Binary files /dev/null and b/assets/screenshots/2020-12-16-github-codeql-pr/pr-blocked.png differ diff --git a/assets/screenshots/2020-12-16-github-codeql-pr/pr-detected-errors.png b/assets/screenshots/2020-12-16-github-codeql-pr/pr-detected-errors.png new file mode 100644 index 0000000..49ec515 Binary files /dev/null and b/assets/screenshots/2020-12-16-github-codeql-pr/pr-detected-errors.png differ diff --git a/assets/screenshots/2020-12-16-github-codeql-pr/pr-no-branch-protection.png b/assets/screenshots/2020-12-16-github-codeql-pr/pr-no-branch-protection.png new file mode 100644 index 0000000..76aeed3 Binary files /dev/null and b/assets/screenshots/2020-12-16-github-codeql-pr/pr-no-branch-protection.png differ diff --git a/assets/screenshots/2020-12-16-github-codeql-pr/pr-passing.png b/assets/screenshots/2020-12-16-github-codeql-pr/pr-passing.png new file mode 100644 index 0000000..619db1d Binary files /dev/null and b/assets/screenshots/2020-12-16-github-codeql-pr/pr-passing.png differ diff --git a/assets/screenshots/2020-12-16-github-codeql-pr/pr.png b/assets/screenshots/2020-12-16-github-codeql-pr/pr.png new file mode 100644 index 0000000..7f071cc Binary files /dev/null and b/assets/screenshots/2020-12-16-github-codeql-pr/pr.png differ diff --git a/assets/screenshots/2020-12-20-nuget-pusher-script/azure-artifacts-dark-mode.png b/assets/screenshots/2020-12-20-nuget-pusher-script/azure-artifacts-dark-mode.png new file mode 100644 index 0000000..6aac9fc Binary files /dev/null and b/assets/screenshots/2020-12-20-nuget-pusher-script/azure-artifacts-dark-mode.png differ diff --git a/assets/screenshots/2020-12-20-nuget-pusher-script/azure-artifacts.png b/assets/screenshots/2020-12-20-nuget-pusher-script/azure-artifacts.png new file mode 100644 index 0000000..eae07cd Binary files /dev/null and b/assets/screenshots/2020-12-20-nuget-pusher-script/azure-artifacts.png differ diff --git a/assets/screenshots/2021-01-26-trac-to-github/trac-hub-example-issue-comments.png b/assets/screenshots/2021-01-26-trac-to-github/trac-hub-example-issue-comments.png new file mode 100644 index 0000000..3ec805d Binary files /dev/null and b/assets/screenshots/2021-01-26-trac-to-github/trac-hub-example-issue-comments.png differ diff --git a/assets/screenshots/2021-01-26-trac-to-github/trac-hub-example-issues.png b/assets/screenshots/2021-01-26-trac-to-github/trac-hub-example-issues.png new file mode 100644 index 0000000..482c553 Binary files /dev/null and b/assets/screenshots/2021-01-26-trac-to-github/trac-hub-example-issues.png differ diff --git a/assets/screenshots/2021-01-26-trac-to-github/trac-ticket-comments.png b/assets/screenshots/2021-01-26-trac-to-github/trac-ticket-comments.png new file mode 100644 index 0000000..d88d813 Binary files /dev/null and b/assets/screenshots/2021-01-26-trac-to-github/trac-ticket-comments.png differ diff --git a/assets/screenshots/2021-01-26-trac-to-github/trac-tickets.png b/assets/screenshots/2021-01-26-trac-to-github/trac-tickets.png new file mode 100644 index 0000000..5703e8b Binary files /dev/null and b/assets/screenshots/2021-01-26-trac-to-github/trac-tickets.png differ diff --git a/assets/screenshots/2021-01-26-trac-to-github/trac2github-example-issue-comments.png b/assets/screenshots/2021-01-26-trac-to-github/trac2github-example-issue-comments.png new file mode 100644 index 0000000..7195419 Binary files /dev/null and b/assets/screenshots/2021-01-26-trac-to-github/trac2github-example-issue-comments.png differ diff --git a/assets/screenshots/2021-01-26-trac-to-github/trac2github-example-issues.png b/assets/screenshots/2021-01-26-trac-to-github/trac2github-example-issues.png new file mode 100644 index 0000000..e4aaf5f Binary files /dev/null and b/assets/screenshots/2021-01-26-trac-to-github/trac2github-example-issues.png differ diff --git a/assets/screenshots/2021-06-17-angular-tokenization/main.js.png b/assets/screenshots/2021-06-17-angular-tokenization/main.js.png new file mode 100644 index 0000000..26a6d7e Binary files /dev/null and b/assets/screenshots/2021-06-17-angular-tokenization/main.js.png differ diff --git a/assets/screenshots/2021-06-17-angular-tokenization/replace-tokens.png b/assets/screenshots/2021-06-17-angular-tokenization/replace-tokens.png new file mode 100644 index 0000000..05bb7be Binary files /dev/null and b/assets/screenshots/2021-06-17-angular-tokenization/replace-tokens.png differ diff --git a/assets/screenshots/2021-08-10-azdo-delete-custom-field/postman-auth.png b/assets/screenshots/2021-08-10-azdo-delete-custom-field/postman-auth.png new file mode 100644 index 0000000..2fbdc6a Binary files /dev/null and b/assets/screenshots/2021-08-10-azdo-delete-custom-field/postman-auth.png differ diff --git a/assets/screenshots/2021-08-10-azdo-delete-custom-field/postman-response.png b/assets/screenshots/2021-08-10-azdo-delete-custom-field/postman-response.png new file mode 100644 index 0000000..24829bb Binary files /dev/null and b/assets/screenshots/2021-08-10-azdo-delete-custom-field/postman-response.png differ diff --git a/assets/screenshots/2021-09-03-azure-devops-code-coverage/adding-test-task.png b/assets/screenshots/2021-09-03-azure-devops-code-coverage/adding-test-task.png new file mode 100644 index 0000000..58d25aa Binary files /dev/null and b/assets/screenshots/2021-09-03-azure-devops-code-coverage/adding-test-task.png differ diff --git a/assets/screenshots/2021-09-03-azure-devops-code-coverage/bad-code-coverage.png b/assets/screenshots/2021-09-03-azure-devops-code-coverage/bad-code-coverage.png new file mode 100644 index 0000000..4ce9aa8 Binary files /dev/null and b/assets/screenshots/2021-09-03-azure-devops-code-coverage/bad-code-coverage.png differ diff --git a/assets/screenshots/2021-09-03-azure-devops-code-coverage/dotnet-add-package.png b/assets/screenshots/2021-09-03-azure-devops-code-coverage/dotnet-add-package.png new file mode 100644 index 0000000..0b75449 Binary files /dev/null and b/assets/screenshots/2021-09-03-azure-devops-code-coverage/dotnet-add-package.png differ diff --git a/assets/screenshots/2021-09-03-azure-devops-code-coverage/find-code-coverage.png b/assets/screenshots/2021-09-03-azure-devops-code-coverage/find-code-coverage.png new file mode 100644 index 0000000..a2a8b96 Binary files /dev/null and b/assets/screenshots/2021-09-03-azure-devops-code-coverage/find-code-coverage.png differ diff --git a/assets/screenshots/2021-09-03-azure-devops-code-coverage/good-code-coverage.png b/assets/screenshots/2021-09-03-azure-devops-code-coverage/good-code-coverage.png new file mode 100644 index 0000000..3d65c95 Binary files /dev/null and b/assets/screenshots/2021-09-03-azure-devops-code-coverage/good-code-coverage.png differ diff --git a/assets/screenshots/2021-09-08-github-code-coverage/github-action-code-coverage-job-summary.png b/assets/screenshots/2021-09-08-github-code-coverage/github-action-code-coverage-job-summary.png new file mode 100644 index 0000000..fc3ec2c Binary files /dev/null and b/assets/screenshots/2021-09-08-github-code-coverage/github-action-code-coverage-job-summary.png differ diff --git a/assets/screenshots/2021-09-08-github-code-coverage/github-action-code-coverage.png b/assets/screenshots/2021-09-08-github-code-coverage/github-action-code-coverage.png new file mode 100644 index 0000000..3006d8b Binary files /dev/null and b/assets/screenshots/2021-09-08-github-code-coverage/github-action-code-coverage.png differ diff --git a/assets/screenshots/2021-09-08-github-code-coverage/github-action-pr-post-image-dark-mode.png b/assets/screenshots/2021-09-08-github-code-coverage/github-action-pr-post-image-dark-mode.png new file mode 100644 index 0000000..1124b36 Binary files /dev/null and b/assets/screenshots/2021-09-08-github-code-coverage/github-action-pr-post-image-dark-mode.png differ diff --git a/assets/screenshots/2021-09-08-github-code-coverage/github-action-pr-post-image.png b/assets/screenshots/2021-09-08-github-code-coverage/github-action-pr-post-image.png new file mode 100644 index 0000000..0a58fad Binary files /dev/null and b/assets/screenshots/2021-09-08-github-code-coverage/github-action-pr-post-image.png differ diff --git a/assets/screenshots/2021-09-08-github-code-coverage/github-action-pr.png b/assets/screenshots/2021-09-08-github-code-coverage/github-action-pr.png new file mode 100644 index 0000000..3238889 Binary files /dev/null and b/assets/screenshots/2021-09-08-github-code-coverage/github-action-pr.png differ diff --git a/assets/screenshots/2021-09-08-github-code-coverage/github-action-reportgenerator-job-summary.png b/assets/screenshots/2021-09-08-github-code-coverage/github-action-reportgenerator-job-summary.png new file mode 100644 index 0000000..4242c49 Binary files /dev/null and b/assets/screenshots/2021-09-08-github-code-coverage/github-action-reportgenerator-job-summary.png differ diff --git a/assets/screenshots/2021-09-29-azure-devops-migrate-work-items/bulk-move-team-project.png b/assets/screenshots/2021-09-29-azure-devops-migrate-work-items/bulk-move-team-project.png new file mode 100644 index 0000000..4331784 Binary files /dev/null and b/assets/screenshots/2021-09-29-azure-devops-migrate-work-items/bulk-move-team-project.png differ diff --git a/assets/screenshots/2021-10-01-azure-frontdoor-preview-experience/front-door-overview-expanded.png b/assets/screenshots/2021-10-01-azure-frontdoor-preview-experience/front-door-overview-expanded.png new file mode 100644 index 0000000..4cfa8c5 Binary files /dev/null and b/assets/screenshots/2021-10-01-azure-frontdoor-preview-experience/front-door-overview-expanded.png differ diff --git a/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/CacheBuildStep.png b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/CacheBuildStep.png new file mode 100644 index 0000000..9614ec8 Binary files /dev/null and b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/CacheBuildStep.png differ diff --git a/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/build-time-comparison.png b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/build-time-comparison.png new file mode 100644 index 0000000..2cab1af Binary files /dev/null and b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/build-time-comparison.png differ diff --git a/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/load-npm-cache.png b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/load-npm-cache.png new file mode 100644 index 0000000..8077779 Binary files /dev/null and b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/load-npm-cache.png differ diff --git a/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/slow-npm-install.png b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/slow-npm-install.png new file mode 100644 index 0000000..dc8ec11 Binary files /dev/null and b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/slow-npm-install.png differ diff --git a/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/upload-cache.png b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/upload-cache.png new file mode 100644 index 0000000..7e6c00a Binary files /dev/null and b/assets/screenshots/2021-11-14-azdo-angular-pipeline-caching/upload-cache.png differ diff --git a/assets/screenshots/2021-11-23-github-codespaces-powerlevel10k/codespace.png b/assets/screenshots/2021-11-23-github-codespaces-powerlevel10k/codespace.png new file mode 100644 index 0000000..1debdc0 Binary files /dev/null and b/assets/screenshots/2021-11-23-github-codespaces-powerlevel10k/codespace.png differ diff --git a/assets/screenshots/2021-11-23-github-codespaces-powerlevel10k/directory-bad.png b/assets/screenshots/2021-11-23-github-codespaces-powerlevel10k/directory-bad.png new file mode 100644 index 0000000..56a1ac5 Binary files /dev/null and b/assets/screenshots/2021-11-23-github-codespaces-powerlevel10k/directory-bad.png differ diff --git a/assets/screenshots/2021-11-23-github-codespaces-powerlevel10k/directory-good.png b/assets/screenshots/2021-11-23-github-codespaces-powerlevel10k/directory-good.png new file mode 100644 index 0000000..c159868 Binary files /dev/null and b/assets/screenshots/2021-11-23-github-codespaces-powerlevel10k/directory-good.png differ diff --git a/assets/screenshots/2021-12-03-github-advanced-security-feature-chart/organization-security-overview.png b/assets/screenshots/2021-12-03-github-advanced-security-feature-chart/organization-security-overview.png new file mode 100644 index 0000000..ed0dc1b Binary files /dev/null and b/assets/screenshots/2021-12-03-github-advanced-security-feature-chart/organization-security-overview.png differ diff --git a/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/authorize-github.png b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/authorize-github.png new file mode 100644 index 0000000..5015e41 Binary files /dev/null and b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/authorize-github.png differ diff --git a/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/azure-boards-github.png b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/azure-boards-github.png new file mode 100644 index 0000000..5628774 Binary files /dev/null and b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/azure-boards-github.png differ diff --git a/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/example-org-1.png b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/example-org-1.png new file mode 100644 index 0000000..6ae0f89 Binary files /dev/null and b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/example-org-1.png differ diff --git a/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/example-org-2.png b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/example-org-2.png new file mode 100644 index 0000000..4bda7c0 Binary files /dev/null and b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/example-org-2.png differ diff --git a/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/multiple-repos-linked.png b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/multiple-repos-linked.png new file mode 100644 index 0000000..86e642f Binary files /dev/null and b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/multiple-repos-linked.png differ diff --git a/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/null-error.png b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/null-error.png new file mode 100644 index 0000000..874a560 Binary files /dev/null and b/assets/screenshots/2021-12-09-github-connecting-to-azure-boards-multiple-orgs/null-error.png differ diff --git a/assets/screenshots/2022-01-04-migrate-svn-to-git/option1-azdo-tags-as-branches.png b/assets/screenshots/2022-01-04-migrate-svn-to-git/option1-azdo-tags-as-branches.png new file mode 100644 index 0000000..f253e24 Binary files /dev/null and b/assets/screenshots/2022-01-04-migrate-svn-to-git/option1-azdo-tags-as-branches.png differ diff --git a/assets/screenshots/2022-01-04-migrate-svn-to-git/option1-github-tags-as-branches.png b/assets/screenshots/2022-01-04-migrate-svn-to-git/option1-github-tags-as-branches.png new file mode 100644 index 0000000..7b6b0b7 Binary files /dev/null and b/assets/screenshots/2022-01-04-migrate-svn-to-git/option1-github-tags-as-branches.png differ diff --git a/assets/screenshots/2022-01-04-migrate-svn-to-git/option2-azdo-tags-as-tags.png b/assets/screenshots/2022-01-04-migrate-svn-to-git/option2-azdo-tags-as-tags.png new file mode 100644 index 0000000..78ac334 Binary files /dev/null and b/assets/screenshots/2022-01-04-migrate-svn-to-git/option2-azdo-tags-as-tags.png differ diff --git a/assets/screenshots/2022-01-04-migrate-svn-to-git/option2-github-tags-as-tags.png b/assets/screenshots/2022-01-04-migrate-svn-to-git/option2-github-tags-as-tags.png new file mode 100644 index 0000000..aff88d8 Binary files /dev/null and b/assets/screenshots/2022-01-04-migrate-svn-to-git/option2-github-tags-as-tags.png differ diff --git a/assets/screenshots/2022-01-04-migrate-svn-to-git/svn-to-git.png b/assets/screenshots/2022-01-04-migrate-svn-to-git/svn-to-git.png new file mode 100644 index 0000000..abfd371 Binary files /dev/null and b/assets/screenshots/2022-01-04-migrate-svn-to-git/svn-to-git.png differ diff --git a/assets/screenshots/2022-01-07-github-download-from-github-packages/github-packages.png b/assets/screenshots/2022-01-07-github-download-from-github-packages/github-packages.png new file mode 100644 index 0000000..fe7c3d8 Binary files /dev/null and b/assets/screenshots/2022-01-07-github-download-from-github-packages/github-packages.png differ diff --git a/assets/screenshots/2022-01-07-github-download-from-github-packages/github-packages2.png b/assets/screenshots/2022-01-07-github-download-from-github-packages/github-packages2.png new file mode 100644 index 0000000..b624992 Binary files /dev/null and b/assets/screenshots/2022-01-07-github-download-from-github-packages/github-packages2.png differ diff --git a/assets/screenshots/2022-02-07-github-apps/app-bot.png b/assets/screenshots/2022-02-07-github-apps/app-bot.png new file mode 100644 index 0000000..ccbe4b3 Binary files /dev/null and b/assets/screenshots/2022-02-07-github-apps/app-bot.png differ diff --git a/assets/screenshots/2022-02-07-github-apps/clone-from-app.png b/assets/screenshots/2022-02-07-github-apps/clone-from-app.png new file mode 100644 index 0000000..6edfe4e Binary files /dev/null and b/assets/screenshots/2022-02-07-github-apps/clone-from-app.png differ diff --git a/assets/screenshots/2022-02-07-github-apps/gh-token.png b/assets/screenshots/2022-02-07-github-apps/gh-token.png new file mode 100644 index 0000000..89831a2 Binary files /dev/null and b/assets/screenshots/2022-02-07-github-apps/gh-token.png differ diff --git a/assets/screenshots/2022-02-07-github-apps/github-actions-bot.png b/assets/screenshots/2022-02-07-github-apps/github-actions-bot.png new file mode 100644 index 0000000..f4cac8f Binary files /dev/null and b/assets/screenshots/2022-02-07-github-apps/github-actions-bot.png differ diff --git a/assets/screenshots/2022-02-07-github-apps/github-apps-light-mode.png b/assets/screenshots/2022-02-07-github-apps/github-apps-light-mode.png new file mode 100644 index 0000000..32557a0 Binary files /dev/null and b/assets/screenshots/2022-02-07-github-apps/github-apps-light-mode.png differ diff --git a/assets/screenshots/2022-02-07-github-apps/github-apps.png b/assets/screenshots/2022-02-07-github-apps/github-apps.png new file mode 100644 index 0000000..dd055f4 Binary files /dev/null and b/assets/screenshots/2022-02-07-github-apps/github-apps.png differ diff --git a/assets/screenshots/2022-02-07-github-apps/installation-id-github.png b/assets/screenshots/2022-02-07-github-apps/installation-id-github.png new file mode 100644 index 0000000..8ec7948 Binary files /dev/null and b/assets/screenshots/2022-02-07-github-apps/installation-id-github.png differ diff --git a/assets/screenshots/2022-02-07-github-apps/installation-id.png b/assets/screenshots/2022-02-07-github-apps/installation-id.png new file mode 100644 index 0000000..36a9ab5 Binary files /dev/null and b/assets/screenshots/2022-02-07-github-apps/installation-id.png differ diff --git a/assets/screenshots/2022-02-07-github-apps/pat-bot.png b/assets/screenshots/2022-02-07-github-apps/pat-bot.png new file mode 100644 index 0000000..eb9d2e6 Binary files /dev/null and b/assets/screenshots/2022-02-07-github-apps/pat-bot.png differ diff --git a/assets/screenshots/2022-02-08-github-approveops/approveops-action-run.png b/assets/screenshots/2022-02-08-github-approveops/approveops-action-run.png new file mode 100644 index 0000000..12880f8 Binary files /dev/null and b/assets/screenshots/2022-02-08-github-approveops/approveops-action-run.png differ diff --git a/assets/screenshots/2022-02-08-github-approveops/approveops-comments-light-mode.png b/assets/screenshots/2022-02-08-github-approveops/approveops-comments-light-mode.png new file mode 100644 index 0000000..605e082 Binary files /dev/null and b/assets/screenshots/2022-02-08-github-approveops/approveops-comments-light-mode.png differ diff --git a/assets/screenshots/2022-02-08-github-approveops/approveops-comments.png b/assets/screenshots/2022-02-08-github-approveops/approveops-comments.png new file mode 100644 index 0000000..9e91b77 Binary files /dev/null and b/assets/screenshots/2022-02-08-github-approveops/approveops-comments.png differ diff --git a/assets/screenshots/2022-02-08-github-approveops/approveops.png b/assets/screenshots/2022-02-08-github-approveops/approveops.png new file mode 100644 index 0000000..d0937a3 Binary files /dev/null and b/assets/screenshots/2022-02-08-github-approveops/approveops.png differ diff --git a/assets/screenshots/2022-03-07-github-container-jobs/container-action-only-windows.png b/assets/screenshots/2022-03-07-github-container-jobs/container-action-only-windows.png new file mode 100644 index 0000000..467f123 Binary files /dev/null and b/assets/screenshots/2022-03-07-github-container-jobs/container-action-only-windows.png differ diff --git a/assets/screenshots/2022-03-07-github-container-jobs/container-cant-run-in-container.png b/assets/screenshots/2022-03-07-github-container-jobs/container-cant-run-in-container.png new file mode 100644 index 0000000..492bb90 Binary files /dev/null and b/assets/screenshots/2022-03-07-github-container-jobs/container-cant-run-in-container.png differ diff --git a/assets/screenshots/2022-03-07-github-container-jobs/container-job-post-image-light-mode.png b/assets/screenshots/2022-03-07-github-container-jobs/container-job-post-image-light-mode.png new file mode 100644 index 0000000..7f99c65 Binary files /dev/null and b/assets/screenshots/2022-03-07-github-container-jobs/container-job-post-image-light-mode.png differ diff --git a/assets/screenshots/2022-03-07-github-container-jobs/container-job-post-image.png b/assets/screenshots/2022-03-07-github-container-jobs/container-job-post-image.png new file mode 100644 index 0000000..153ed37 Binary files /dev/null and b/assets/screenshots/2022-03-07-github-container-jobs/container-job-post-image.png differ diff --git a/assets/screenshots/2022-03-07-github-container-jobs/container-job.png b/assets/screenshots/2022-03-07-github-container-jobs/container-job.png new file mode 100644 index 0000000..271617b Binary files /dev/null and b/assets/screenshots/2022-03-07-github-container-jobs/container-job.png differ diff --git a/assets/screenshots/2022-03-08-github-advanced-security-permissions-chart/custom-roles.png b/assets/screenshots/2022-03-08-github-advanced-security-permissions-chart/custom-roles.png new file mode 100644 index 0000000..9c5d170 Binary files /dev/null and b/assets/screenshots/2022-03-08-github-advanced-security-permissions-chart/custom-roles.png differ diff --git a/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-closed.png b/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-closed.png new file mode 100644 index 0000000..a6f4e5f Binary files /dev/null and b/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-closed.png differ diff --git a/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-history.png b/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-history.png new file mode 100644 index 0000000..e9a1bb3 Binary files /dev/null and b/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-history.png differ diff --git a/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-test-autofilter.png b/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-test-autofilter.png new file mode 100644 index 0000000..2056ac2 Binary files /dev/null and b/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-test-autofilter.png differ diff --git a/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-test.png b/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-test.png new file mode 100644 index 0000000..bf0ce0d Binary files /dev/null and b/assets/screenshots/2022-03-09-github-codeql-ignore-files/codeql-test.png differ diff --git a/assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts/dependabot-logs.png b/assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts/dependabot-logs.png new file mode 100644 index 0000000..e742b35 Binary files /dev/null and b/assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts/dependabot-logs.png differ diff --git a/assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts/dependabot-pr.png b/assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts/dependabot-pr.png new file mode 100644 index 0000000..317f747 Binary files /dev/null and b/assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts/dependabot-pr.png differ diff --git a/assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts/dependabot-update.png b/assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts/dependabot-update.png new file mode 100644 index 0000000..21cacdf Binary files /dev/null and b/assets/screenshots/2022-03-10-github-dependabot-with-azure-artifacts/dependabot-update.png differ diff --git a/assets/screenshots/2022-03-11-migrate-azure-devops-work-items-to-github-issues/migrated-issue.png b/assets/screenshots/2022-03-11-migrate-azure-devops-work-items-to-github-issues/migrated-issue.png new file mode 100644 index 0000000..8ca5549 Binary files /dev/null and b/assets/screenshots/2022-03-11-migrate-azure-devops-work-items-to-github-issues/migrated-issue.png differ diff --git a/assets/screenshots/2022-04-06-dependency-review-action/dependency-review-action.png b/assets/screenshots/2022-04-06-dependency-review-action/dependency-review-action.png new file mode 100644 index 0000000..a736395 Binary files /dev/null and b/assets/screenshots/2022-04-06-dependency-review-action/dependency-review-action.png differ diff --git a/assets/screenshots/2022-04-06-dependency-review-action/dependency-review-rich-diff.png b/assets/screenshots/2022-04-06-dependency-review-action/dependency-review-rich-diff.png new file mode 100644 index 0000000..b7043e4 Binary files /dev/null and b/assets/screenshots/2022-04-06-dependency-review-action/dependency-review-rich-diff.png differ diff --git a/assets/screenshots/2022-04-06-dependency-review-action/pull-request-status-checks.png b/assets/screenshots/2022-04-06-dependency-review-action/pull-request-status-checks.png new file mode 100644 index 0000000..ef91341 Binary files /dev/null and b/assets/screenshots/2022-04-06-dependency-review-action/pull-request-status-checks.png differ diff --git a/assets/screenshots/2022-06-28-actions-runner-controller-without-cert-manager/pods.png b/assets/screenshots/2022-06-28-actions-runner-controller-without-cert-manager/pods.png new file mode 100644 index 0000000..3f720f6 Binary files /dev/null and b/assets/screenshots/2022-06-28-actions-runner-controller-without-cert-manager/pods.png differ diff --git a/assets/screenshots/2022-06-28-actions-runner-controller-without-cert-manager/runners.png b/assets/screenshots/2022-06-28-actions-runner-controller-without-cert-manager/runners.png new file mode 100644 index 0000000..e4de3f6 Binary files /dev/null and b/assets/screenshots/2022-06-28-actions-runner-controller-without-cert-manager/runners.png differ diff --git a/assets/screenshots/2022-07-01-my-macos-development-environment/iterm2.png b/assets/screenshots/2022-07-01-my-macos-development-environment/iterm2.png new file mode 100644 index 0000000..9d92bc4 Binary files /dev/null and b/assets/screenshots/2022-07-01-my-macos-development-environment/iterm2.png differ diff --git a/assets/screenshots/2022-07-01-my-macos-development-environment/vscode.png b/assets/screenshots/2022-07-01-my-macos-development-environment/vscode.png new file mode 100644 index 0000000..6c08a45 Binary files /dev/null and b/assets/screenshots/2022-07-01-my-macos-development-environment/vscode.png differ diff --git a/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-error.png b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-error.png new file mode 100644 index 0000000..134229b Binary files /dev/null and b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-error.png differ diff --git a/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-pr-post-image-light-mode.png b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-pr-post-image-light-mode.png new file mode 100644 index 0000000..90cb217 Binary files /dev/null and b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-pr-post-image-light-mode.png differ diff --git a/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-pr-post-image.png b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-pr-post-image.png new file mode 100644 index 0000000..54218bc Binary files /dev/null and b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-pr-post-image.png differ diff --git a/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-pr.png b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-pr.png new file mode 100644 index 0000000..8039572 Binary files /dev/null and b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-pr.png differ diff --git a/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-private-repos.png b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-private-repos.png new file mode 100644 index 0000000..1dd5e96 Binary files /dev/null and b/assets/screenshots/2022-07-02-github-dependabot-for-actions/dependabot-private-repos.png differ diff --git a/assets/screenshots/2022-08-03-lap-around-github-advanced-security/video-preview.png b/assets/screenshots/2022-08-03-lap-around-github-advanced-security/video-preview.png new file mode 100644 index 0000000..63141e2 Binary files /dev/null and b/assets/screenshots/2022-08-03-lap-around-github-advanced-security/video-preview.png differ diff --git a/assets/screenshots/2022-08-11-github-script-to-add-users-to-teams/github-team-dark-mode.png b/assets/screenshots/2022-08-11-github-script-to-add-users-to-teams/github-team-dark-mode.png new file mode 100644 index 0000000..76ee6db Binary files /dev/null and b/assets/screenshots/2022-08-11-github-script-to-add-users-to-teams/github-team-dark-mode.png differ diff --git a/assets/screenshots/2022-08-11-github-script-to-add-users-to-teams/github-team.png b/assets/screenshots/2022-08-11-github-script-to-add-users-to-teams/github-team.png new file mode 100644 index 0000000..220afc5 Binary files /dev/null and b/assets/screenshots/2022-08-11-github-script-to-add-users-to-teams/github-team.png differ diff --git a/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/blocking-pr-post-image-light-mode.png b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/blocking-pr-post-image-light-mode.png new file mode 100644 index 0000000..3e11865 Binary files /dev/null and b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/blocking-pr-post-image-light-mode.png differ diff --git a/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/blocking-pr-post-image.png b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/blocking-pr-post-image.png new file mode 100644 index 0000000..662e9fe Binary files /dev/null and b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/blocking-pr-post-image.png differ diff --git a/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/blocking-pr.png b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/blocking-pr.png new file mode 100644 index 0000000..9efc4fb Binary files /dev/null and b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/blocking-pr.png differ diff --git a/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/branch-protection-policy.png b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/branch-protection-policy.png new file mode 100644 index 0000000..354e6c1 Binary files /dev/null and b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/branch-protection-policy.png differ diff --git a/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/checks-failing-on-pr.png b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/checks-failing-on-pr.png new file mode 100644 index 0000000..4edaf3f Binary files /dev/null and b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/checks-failing-on-pr.png differ diff --git a/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/linking-workitem-to-pr.png b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/linking-workitem-to-pr.png new file mode 100644 index 0000000..1f754ee Binary files /dev/null and b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/linking-workitem-to-pr.png differ diff --git a/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/pr-link.png b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/pr-link.png new file mode 100644 index 0000000..b9ed98a Binary files /dev/null and b/assets/screenshots/2022-08-17-azdo-commit-message-validator-and-pr-linker-github-action/pr-link.png differ diff --git a/assets/screenshots/2022-09-30-migrating-repos-to-github/git-config.png b/assets/screenshots/2022-09-30-migrating-repos-to-github/git-config.png new file mode 100644 index 0000000..5e0bc29 Binary files /dev/null and b/assets/screenshots/2022-09-30-migrating-repos-to-github/git-config.png differ diff --git a/assets/screenshots/2022-09-30-migrating-repos-to-github/import-repo-github-light-mode.gif b/assets/screenshots/2022-09-30-migrating-repos-to-github/import-repo-github-light-mode.gif new file mode 100644 index 0000000..2bdc07e Binary files /dev/null and b/assets/screenshots/2022-09-30-migrating-repos-to-github/import-repo-github-light-mode.gif differ diff --git a/assets/screenshots/2022-09-30-migrating-repos-to-github/import-repo-github-old.gif b/assets/screenshots/2022-09-30-migrating-repos-to-github/import-repo-github-old.gif new file mode 100644 index 0000000..a0e0aa3 Binary files /dev/null and b/assets/screenshots/2022-09-30-migrating-repos-to-github/import-repo-github-old.gif differ diff --git a/assets/screenshots/2022-09-30-migrating-repos-to-github/import-repo-github.gif b/assets/screenshots/2022-09-30-migrating-repos-to-github/import-repo-github.gif new file mode 100644 index 0000000..b81a728 Binary files /dev/null and b/assets/screenshots/2022-09-30-migrating-repos-to-github/import-repo-github.gif differ diff --git a/assets/screenshots/2022-10-06-github-script-to-delete-repos/delete-repo-dark-mode.png b/assets/screenshots/2022-10-06-github-script-to-delete-repos/delete-repo-dark-mode.png new file mode 100644 index 0000000..a750e53 Binary files /dev/null and b/assets/screenshots/2022-10-06-github-script-to-delete-repos/delete-repo-dark-mode.png differ diff --git a/assets/screenshots/2022-10-06-github-script-to-delete-repos/delete-repo.png b/assets/screenshots/2022-10-06-github-script-to-delete-repos/delete-repo.png new file mode 100644 index 0000000..8b6490b Binary files /dev/null and b/assets/screenshots/2022-10-06-github-script-to-delete-repos/delete-repo.png differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/annotation-example.png b/assets/screenshots/2022-10-12-using-github-checks-api/annotation-example.png new file mode 100644 index 0000000..6d348b9 Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/annotation-example.png differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/check_runs.png b/assets/screenshots/2022-10-12-using-github-checks-api/check_runs.png new file mode 100644 index 0000000..4c32ab9 Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/check_runs.png differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/github-app-check-url.png b/assets/screenshots/2022-10-12-using-github-checks-api/github-app-check-url.png new file mode 100644 index 0000000..245d1bb Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/github-app-check-url.png differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/github-app-check.png b/assets/screenshots/2022-10-12-using-github-checks-api/github-app-check.png new file mode 100644 index 0000000..f7e5a4d Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/github-app-check.png differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/github-token-check-url.png b/assets/screenshots/2022-10-12-using-github-checks-api/github-token-check-url.png new file mode 100644 index 0000000..239ed9b Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/github-token-check-url.png differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/github-token-check.png b/assets/screenshots/2022-10-12-using-github-checks-api/github-token-check.png new file mode 100644 index 0000000..ec14ec9 Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/github-token-check.png differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/github_apps_checks_fix_this_button.png b/assets/screenshots/2022-10-12-using-github-checks-api/github_apps_checks_fix_this_button.png new file mode 100644 index 0000000..ddb3c1f Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/github_apps_checks_fix_this_button.png differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/status-check-light-mode.gif b/assets/screenshots/2022-10-12-using-github-checks-api/status-check-light-mode.gif new file mode 100644 index 0000000..fc66b43 Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/status-check-light-mode.gif differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/status-check-old-post-image.gif b/assets/screenshots/2022-10-12-using-github-checks-api/status-check-old-post-image.gif new file mode 100644 index 0000000..452f375 Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/status-check-old-post-image.gif differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/status-check-old.gif b/assets/screenshots/2022-10-12-using-github-checks-api/status-check-old.gif new file mode 100644 index 0000000..f44134d Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/status-check-old.gif differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/status-check.gif b/assets/screenshots/2022-10-12-using-github-checks-api/status-check.gif new file mode 100644 index 0000000..1b76621 Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/status-check.gif differ diff --git a/assets/screenshots/2022-10-12-using-github-checks-api/status-checks-missing-deployment-on-pr.png b/assets/screenshots/2022-10-12-using-github-checks-api/status-checks-missing-deployment-on-pr.png new file mode 100644 index 0000000..a883698 Binary files /dev/null and b/assets/screenshots/2022-10-12-using-github-checks-api/status-checks-missing-deployment-on-pr.png differ diff --git a/assets/screenshots/2022-11-23-github-packages-migrate-nuget-packages/github-packages-dark-mode.png b/assets/screenshots/2022-11-23-github-packages-migrate-nuget-packages/github-packages-dark-mode.png new file mode 100644 index 0000000..7edc1b8 Binary files /dev/null and b/assets/screenshots/2022-11-23-github-packages-migrate-nuget-packages/github-packages-dark-mode.png differ diff --git a/assets/screenshots/2022-11-23-github-packages-migrate-nuget-packages/github-packages.png b/assets/screenshots/2022-11-23-github-packages-migrate-nuget-packages/github-packages.png new file mode 100644 index 0000000..0836e65 Binary files /dev/null and b/assets/screenshots/2022-11-23-github-packages-migrate-nuget-packages/github-packages.png differ diff --git a/assets/screenshots/2023-02-15-github-download-latest-release/release-dark-mode.png b/assets/screenshots/2023-02-15-github-download-latest-release/release-dark-mode.png new file mode 100644 index 0000000..93988fd Binary files /dev/null and b/assets/screenshots/2023-02-15-github-download-latest-release/release-dark-mode.png differ diff --git a/assets/screenshots/2023-02-15-github-download-latest-release/release.png b/assets/screenshots/2023-02-15-github-download-latest-release/release.png new file mode 100644 index 0000000..900be03 Binary files /dev/null and b/assets/screenshots/2023-02-15-github-download-latest-release/release.png differ diff --git a/assets/screenshots/2023-02-28-security-alerts/security-overview-dark.png b/assets/screenshots/2023-02-28-security-alerts/security-overview-dark.png new file mode 100644 index 0000000..ae0ab4b Binary files /dev/null and b/assets/screenshots/2023-02-28-security-alerts/security-overview-dark.png differ diff --git a/assets/screenshots/2023-02-28-security-alerts/security-overview-light.png b/assets/screenshots/2023-02-28-security-alerts/security-overview-light.png new file mode 100644 index 0000000..36acae5 Binary files /dev/null and b/assets/screenshots/2023-02-28-security-alerts/security-overview-light.png differ diff --git a/assets/screenshots/2023-03-13-deprecated-github-actions-commands/deprecated-workflow-command-dark-mode.png b/assets/screenshots/2023-03-13-deprecated-github-actions-commands/deprecated-workflow-command-dark-mode.png new file mode 100644 index 0000000..9013b68 Binary files /dev/null and b/assets/screenshots/2023-03-13-deprecated-github-actions-commands/deprecated-workflow-command-dark-mode.png differ diff --git a/assets/screenshots/2023-03-13-deprecated-github-actions-commands/deprecated-workflow-command.png b/assets/screenshots/2023-03-13-deprecated-github-actions-commands/deprecated-workflow-command.png new file mode 100644 index 0000000..d76ad54 Binary files /dev/null and b/assets/screenshots/2023-03-13-deprecated-github-actions-commands/deprecated-workflow-command.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-actions-error.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-actions-error.png new file mode 100644 index 0000000..eff1dc8 Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-actions-error.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-error.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-error.png new file mode 100644 index 0000000..1434b8b Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-error.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-full-dark.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-full-dark.png new file mode 100644 index 0000000..68faeeb Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-full-dark.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-full.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-full.png new file mode 100644 index 0000000..d74a28b Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-full.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-grant-access.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-grant-access.png new file mode 100644 index 0000000..72f8662 Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-grant-access.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-non-actions-error.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-non-actions-error.png new file mode 100644 index 0000000..c859c8e Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-non-actions-error.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-pr-dark-mode.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-pr-dark-mode.png new file mode 100644 index 0000000..c2c3e28 Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-pr-dark-mode.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-pr.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-pr.png new file mode 100644 index 0000000..f4d57af Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-pr.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-private-repositories-clean.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-private-repositories-clean.png new file mode 100644 index 0000000..98f6943 Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-private-repositories-clean.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-private-repositories.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-private-repositories.png new file mode 100644 index 0000000..788cb6a Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-private-repositories.png differ diff --git a/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-success.png b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-success.png new file mode 100644 index 0000000..050cb99 Binary files /dev/null and b/assets/screenshots/2023-03-15-dependabot-reusable-workflows/dependabot-success.png differ diff --git a/assets/screenshots/2023-03-22-github-script-to-create-teams/create-team-dark-mode.png b/assets/screenshots/2023-03-22-github-script-to-create-teams/create-team-dark-mode.png new file mode 100644 index 0000000..f85e714 Binary files /dev/null and b/assets/screenshots/2023-03-22-github-script-to-create-teams/create-team-dark-mode.png differ diff --git a/assets/screenshots/2023-03-22-github-script-to-create-teams/create-team.png b/assets/screenshots/2023-03-22-github-script-to-create-teams/create-team.png new file mode 100644 index 0000000..624706c Binary files /dev/null and b/assets/screenshots/2023-03-22-github-script-to-create-teams/create-team.png differ diff --git a/assets/screenshots/2023-06-21-github-signing-commits/verified-commits-dark-mode.png b/assets/screenshots/2023-06-21-github-signing-commits/verified-commits-dark-mode.png new file mode 100644 index 0000000..c41fc1b Binary files /dev/null and b/assets/screenshots/2023-06-21-github-signing-commits/verified-commits-dark-mode.png differ diff --git a/assets/screenshots/2023-06-21-github-signing-commits/verified-commits.png b/assets/screenshots/2023-06-21-github-signing-commits/verified-commits.png new file mode 100644 index 0000000..a9a0343 Binary files /dev/null and b/assets/screenshots/2023-06-21-github-signing-commits/verified-commits.png differ diff --git a/assets/screenshots/2023-06-21-storing-certificates-as-github-secrets/secrets-dark.png b/assets/screenshots/2023-06-21-storing-certificates-as-github-secrets/secrets-dark.png new file mode 100644 index 0000000..7e4ce38 Binary files /dev/null and b/assets/screenshots/2023-06-21-storing-certificates-as-github-secrets/secrets-dark.png differ diff --git a/assets/screenshots/2023-06-21-storing-certificates-as-github-secrets/secrets.png b/assets/screenshots/2023-06-21-storing-certificates-as-github-secrets/secrets.png new file mode 100644 index 0000000..d005a53 Binary files /dev/null and b/assets/screenshots/2023-06-21-storing-certificates-as-github-secrets/secrets.png differ diff --git a/assets/screenshots/2023-06-22-github-actions-tokenization/tokenization-dark-mode.png b/assets/screenshots/2023-06-22-github-actions-tokenization/tokenization-dark-mode.png new file mode 100644 index 0000000..f7ba578 Binary files /dev/null and b/assets/screenshots/2023-06-22-github-actions-tokenization/tokenization-dark-mode.png differ diff --git a/assets/screenshots/2023-06-22-github-actions-tokenization/tokenization.png b/assets/screenshots/2023-06-22-github-actions-tokenization/tokenization.png new file mode 100644 index 0000000..09d9762 Binary files /dev/null and b/assets/screenshots/2023-06-22-github-actions-tokenization/tokenization.png differ diff --git a/assets/screenshots/2023-09-07-add-files-to-git-lfs/git-lfs-add.png b/assets/screenshots/2023-09-07-add-files-to-git-lfs/git-lfs-add.png new file mode 100644 index 0000000..b02fcd3 Binary files /dev/null and b/assets/screenshots/2023-09-07-add-files-to-git-lfs/git-lfs-add.png differ diff --git a/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-commands.png b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-commands.png new file mode 100644 index 0000000..8b098db Binary files /dev/null and b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-commands.png differ diff --git a/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-dark.png b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-dark.png new file mode 100644 index 0000000..458f080 Binary files /dev/null and b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-dark.png differ diff --git a/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-light.png b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-light.png new file mode 100644 index 0000000..474da8c Binary files /dev/null and b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-light.png differ diff --git a/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-pointer-dark.png b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-pointer-dark.png new file mode 100644 index 0000000..c588066 Binary files /dev/null and b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-pointer-dark.png differ diff --git a/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-pointer-light.png b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-pointer-light.png new file mode 100644 index 0000000..871ee34 Binary files /dev/null and b/assets/screenshots/2023-09-07-migrate-git-lfs-artifacts/git-lfs-pointer-light.png differ diff --git a/assets/screenshots/2023-09-07-migrate-to-git-lfs/git-lfs-dark.png b/assets/screenshots/2023-09-07-migrate-to-git-lfs/git-lfs-dark.png new file mode 100644 index 0000000..b0c4d6c Binary files /dev/null and b/assets/screenshots/2023-09-07-migrate-to-git-lfs/git-lfs-dark.png differ diff --git a/assets/screenshots/2023-09-07-migrate-to-git-lfs/git-lfs-light.png b/assets/screenshots/2023-09-07-migrate-to-git-lfs/git-lfs-light.png new file mode 100644 index 0000000..c827797 Binary files /dev/null and b/assets/screenshots/2023-09-07-migrate-to-git-lfs/git-lfs-light.png differ diff --git a/assets/screenshots/2023-09-07-migrate-to-git-lfs/git-lfs-migrate-commands.png b/assets/screenshots/2023-09-07-migrate-to-git-lfs/git-lfs-migrate-commands.png new file mode 100644 index 0000000..c31d603 Binary files /dev/null and b/assets/screenshots/2023-09-07-migrate-to-git-lfs/git-lfs-migrate-commands.png differ diff --git a/assets/screenshots/2023-09-08-github-packages-migrate-npm-packages/npm-packages-dark.png b/assets/screenshots/2023-09-08-github-packages-migrate-npm-packages/npm-packages-dark.png new file mode 100644 index 0000000..ba9da2c Binary files /dev/null and b/assets/screenshots/2023-09-08-github-packages-migrate-npm-packages/npm-packages-dark.png differ diff --git a/assets/screenshots/2023-09-08-github-packages-migrate-npm-packages/npm-packages-light.png b/assets/screenshots/2023-09-08-github-packages-migrate-npm-packages/npm-packages-light.png new file mode 100644 index 0000000..2614eb5 Binary files /dev/null and b/assets/screenshots/2023-09-08-github-packages-migrate-npm-packages/npm-packages-light.png differ diff --git a/assets/screenshots/2023-09-25-github-packages-migrate-maven-packages/maven-packages-dark.png b/assets/screenshots/2023-09-25-github-packages-migrate-maven-packages/maven-packages-dark.png new file mode 100644 index 0000000..69d826a Binary files /dev/null and b/assets/screenshots/2023-09-25-github-packages-migrate-maven-packages/maven-packages-dark.png differ diff --git a/assets/screenshots/2023-09-25-github-packages-migrate-maven-packages/maven-packages-light.png b/assets/screenshots/2023-09-25-github-packages-migrate-maven-packages/maven-packages-light.png new file mode 100644 index 0000000..e8aea74 Binary files /dev/null and b/assets/screenshots/2023-09-25-github-packages-migrate-maven-packages/maven-packages-light.png differ diff --git a/assets/screenshots/2023-12-04-visual-studio-toolbox-github-actions/hqdefault.jpg b/assets/screenshots/2023-12-04-visual-studio-toolbox-github-actions/hqdefault.jpg new file mode 100644 index 0000000..b71c99e Binary files /dev/null and b/assets/screenshots/2023-12-04-visual-studio-toolbox-github-actions/hqdefault.jpg differ diff --git a/assets/screenshots/2023-12-04-visual-studio-toolbox-github-actions/visual-studio-toolbox-github-actions.jpg b/assets/screenshots/2023-12-04-visual-studio-toolbox-github-actions/visual-studio-toolbox-github-actions.jpg new file mode 100644 index 0000000..83bb554 Binary files /dev/null and b/assets/screenshots/2023-12-04-visual-studio-toolbox-github-actions/visual-studio-toolbox-github-actions.jpg differ diff --git a/assets/screenshots/2023-12-05-visual-studio-toolbox-azure-pipelines/hqdefault.jpg b/assets/screenshots/2023-12-05-visual-studio-toolbox-azure-pipelines/hqdefault.jpg new file mode 100644 index 0000000..2318763 Binary files /dev/null and b/assets/screenshots/2023-12-05-visual-studio-toolbox-azure-pipelines/hqdefault.jpg differ diff --git a/assets/screenshots/2023-12-05-visual-studio-toolbox-azure-pipelines/visual-studio-toolbox-azure-pipelines.jpg b/assets/screenshots/2023-12-05-visual-studio-toolbox-azure-pipelines/visual-studio-toolbox-azure-pipelines.jpg new file mode 100644 index 0000000..6a586d8 Binary files /dev/null and b/assets/screenshots/2023-12-05-visual-studio-toolbox-azure-pipelines/visual-studio-toolbox-azure-pipelines.jpg differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-02.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-02.png new file mode 100644 index 0000000..1dd0a4b Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-02.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-04.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-04.png new file mode 100644 index 0000000..c3a0114 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-04.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-05.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-05.png new file mode 100644 index 0000000..70a2eb0 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-05.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-06.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-06.png new file mode 100644 index 0000000..f5b9d54 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-06.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-07.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-07.png new file mode 100644 index 0000000..02d6dc2 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-07.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-08.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-08.png new file mode 100644 index 0000000..6d6fb98 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-08.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-09.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-09.png new file mode 100644 index 0000000..c9148b4 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-09.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-10.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-10.png new file mode 100644 index 0000000..7d5b3df Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-10.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-11-1.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-11-1.png new file mode 100644 index 0000000..0321ee3 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-11-1.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-11.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-11.png new file mode 100644 index 0000000..f118577 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-11.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-12-1.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-12-1.png new file mode 100644 index 0000000..397dd32 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-12-1.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-12.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-12.png new file mode 100644 index 0000000..b632853 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-12.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-13.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-13.png new file mode 100644 index 0000000..128c957 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-13.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-14.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-14.png new file mode 100644 index 0000000..061676a Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-14.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-15.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-15.png new file mode 100644 index 0000000..40b211d Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-15.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-17.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-17.png new file mode 100644 index 0000000..4891241 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-17.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-18.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-18.png new file mode 100644 index 0000000..dc45bd4 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-18.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-19.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-19.png new file mode 100644 index 0000000..a11e207 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-19.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-20.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-20.png new file mode 100644 index 0000000..58b45d1 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-20.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-21.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-21.png new file mode 100644 index 0000000..33f528a Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-21.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-22.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-22.png new file mode 100644 index 0000000..2fe51a5 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-22.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-23.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-23.png new file mode 100644 index 0000000..ebcf3b0 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-23.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-24.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-24.png new file mode 100644 index 0000000..5dbeae3 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-24.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-25.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-25.png new file mode 100644 index 0000000..942a3b7 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-25.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-26.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-26.png new file mode 100644 index 0000000..cbabd98 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-26.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-27.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-27.png new file mode 100644 index 0000000..503ffae Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-27.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-28.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-28.png new file mode 100644 index 0000000..134b17e Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-28.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-30.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-30.png new file mode 100644 index 0000000..559501b Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration-step-30.png differ diff --git a/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration.png b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration.png new file mode 100644 index 0000000..54fb4d5 Binary files /dev/null and b/assets/screenshots/2023-12-18-github-enterprise-server-slack/ghes-slack-integration.png differ diff --git a/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/azure-oidc-dark.png b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/azure-oidc-dark.png new file mode 100644 index 0000000..4165256 Binary files /dev/null and b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/azure-oidc-dark.png differ diff --git a/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/azure-oidc-light.png b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/azure-oidc-light.png new file mode 100644 index 0000000..03d26c0 Binary files /dev/null and b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/azure-oidc-light.png differ diff --git a/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-failure-dark.png b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-failure-dark.png new file mode 100644 index 0000000..13e7c70 Binary files /dev/null and b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-failure-dark.png differ diff --git a/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-failure-light.png b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-failure-light.png new file mode 100644 index 0000000..c2a108d Binary files /dev/null and b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-failure-light.png differ diff --git a/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-success-dark.png b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-success-dark.png new file mode 100644 index 0000000..a4c09b7 Binary files /dev/null and b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-success-dark.png differ diff --git a/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-success-light.png b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-success-light.png new file mode 100644 index 0000000..ab8e8ae Binary files /dev/null and b/assets/screenshots/2023-12-22-github-actions-oidc-reusable-workflows/github-oidc-success-light.png differ diff --git a/assets/screenshots/2023-12-22-github-web-editor-multiline-comment/multiline-comment.gif b/assets/screenshots/2023-12-22-github-web-editor-multiline-comment/multiline-comment.gif new file mode 100644 index 0000000..9d63a33 Binary files /dev/null and b/assets/screenshots/2023-12-22-github-web-editor-multiline-comment/multiline-comment.gif differ diff --git a/assets/screenshots/2024-01-08-github-script-to-add-dependabot-file/dependabot-enable.png b/assets/screenshots/2024-01-08-github-script-to-add-dependabot-file/dependabot-enable.png new file mode 100644 index 0000000..1533c4c Binary files /dev/null and b/assets/screenshots/2024-01-08-github-script-to-add-dependabot-file/dependabot-enable.png differ diff --git a/assets/screenshots/2024-02-03-github-script-to-add-users-to-project/github-project.png b/assets/screenshots/2024-02-03-github-script-to-add-users-to-project/github-project.png new file mode 100644 index 0000000..2a48069 Binary files /dev/null and b/assets/screenshots/2024-02-03-github-script-to-add-users-to-project/github-project.png differ diff --git a/assets/screenshots/2024-03-13-github-packages-migrate-docker-containers/docker-container-github-packages-dark.png b/assets/screenshots/2024-03-13-github-packages-migrate-docker-containers/docker-container-github-packages-dark.png new file mode 100644 index 0000000..4209081 Binary files /dev/null and b/assets/screenshots/2024-03-13-github-packages-migrate-docker-containers/docker-container-github-packages-dark.png differ diff --git a/assets/screenshots/2024-03-13-github-packages-migrate-docker-containers/docker-container-github-packages-light.png b/assets/screenshots/2024-03-13-github-packages-migrate-docker-containers/docker-container-github-packages-light.png new file mode 100644 index 0000000..377f45c Binary files /dev/null and b/assets/screenshots/2024-03-13-github-packages-migrate-docker-containers/docker-container-github-packages-light.png differ diff --git a/assets/screenshots/2024-03-23-github-action-for-twistlock-results/twistlock-job-summary-dark.png b/assets/screenshots/2024-03-23-github-action-for-twistlock-results/twistlock-job-summary-dark.png new file mode 100644 index 0000000..9259236 Binary files /dev/null and b/assets/screenshots/2024-03-23-github-action-for-twistlock-results/twistlock-job-summary-dark.png differ diff --git a/assets/screenshots/2024-03-23-github-action-for-twistlock-results/twistlock-job-summary-light.png b/assets/screenshots/2024-03-23-github-action-for-twistlock-results/twistlock-job-summary-light.png new file mode 100644 index 0000000..5eba92a Binary files /dev/null and b/assets/screenshots/2024-03-23-github-action-for-twistlock-results/twistlock-job-summary-light.png differ diff --git a/assets/screenshots/2024-03-26-github-actions-dynamic-matrix/dynamic-matrix-dark.png b/assets/screenshots/2024-03-26-github-actions-dynamic-matrix/dynamic-matrix-dark.png new file mode 100644 index 0000000..3247b39 Binary files /dev/null and b/assets/screenshots/2024-03-26-github-actions-dynamic-matrix/dynamic-matrix-dark.png differ diff --git a/assets/screenshots/2024-03-26-github-actions-dynamic-matrix/dynamic-matrix-light.png b/assets/screenshots/2024-03-26-github-actions-dynamic-matrix/dynamic-matrix-light.png new file mode 100644 index 0000000..7e71fe5 Binary files /dev/null and b/assets/screenshots/2024-03-26-github-actions-dynamic-matrix/dynamic-matrix-light.png differ diff --git a/assets/screenshots/2024-05-20-github-actions-docker-actions-private-registry/container-hooks-logs-example.png b/assets/screenshots/2024-05-20-github-actions-docker-actions-private-registry/container-hooks-logs-example.png new file mode 100644 index 0000000..5707ef2 Binary files /dev/null and b/assets/screenshots/2024-05-20-github-actions-docker-actions-private-registry/container-hooks-logs-example.png differ diff --git a/assets/screenshots/2024-05-20-github-actions-docker-actions-private-registry/docker-action-composite-action.png b/assets/screenshots/2024-05-20-github-actions-docker-actions-private-registry/docker-action-composite-action.png new file mode 100644 index 0000000..2043451 Binary files /dev/null and b/assets/screenshots/2024-05-20-github-actions-docker-actions-private-registry/docker-action-composite-action.png differ diff --git a/assets/screenshots/2024-05-24-vscode-yaml-indenting/yaml-pasting-indenting-broken.gif b/assets/screenshots/2024-05-24-vscode-yaml-indenting/yaml-pasting-indenting-broken.gif new file mode 100644 index 0000000..a6f93b4 Binary files /dev/null and b/assets/screenshots/2024-05-24-vscode-yaml-indenting/yaml-pasting-indenting-broken.gif differ diff --git a/assets/screenshots/2024-05-24-vscode-yaml-indenting/yaml-pasting-indenting-fixed.gif b/assets/screenshots/2024-05-24-vscode-yaml-indenting/yaml-pasting-indenting-fixed.gif new file mode 100644 index 0000000..8ce8ed1 Binary files /dev/null and b/assets/screenshots/2024-05-24-vscode-yaml-indenting/yaml-pasting-indenting-fixed.gif differ diff --git a/assets/screenshots/2024-06-14-github-context/github-context-dark.png b/assets/screenshots/2024-06-14-github-context/github-context-dark.png new file mode 100644 index 0000000..2f7f4f9 Binary files /dev/null and b/assets/screenshots/2024-06-14-github-context/github-context-dark.png differ diff --git a/assets/screenshots/2024-06-14-github-context/github-context-light.png b/assets/screenshots/2024-06-14-github-context/github-context-light.png new file mode 100644 index 0000000..6de64fc Binary files /dev/null and b/assets/screenshots/2024-06-14-github-context/github-context-light.png differ diff --git a/assets/screenshots/2024-06-18-github-composite-action-python/composite-action-dark.png b/assets/screenshots/2024-06-18-github-composite-action-python/composite-action-dark.png new file mode 100644 index 0000000..939cb14 Binary files /dev/null and b/assets/screenshots/2024-06-18-github-composite-action-python/composite-action-dark.png differ diff --git a/assets/screenshots/2024-06-18-github-composite-action-python/composite-action-light.png b/assets/screenshots/2024-06-18-github-composite-action-python/composite-action-light.png new file mode 100644 index 0000000..7706476 Binary files /dev/null and b/assets/screenshots/2024-06-18-github-composite-action-python/composite-action-light.png differ diff --git a/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-dark-header.png b/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-dark-header.png new file mode 100644 index 0000000..0f17573 Binary files /dev/null and b/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-dark-header.png differ diff --git a/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-dark.png b/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-dark.png new file mode 100644 index 0000000..2a78f6b Binary files /dev/null and b/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-dark.png differ diff --git a/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-light-header.png b/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-light-header.png new file mode 100644 index 0000000..61492b5 Binary files /dev/null and b/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-light-header.png differ diff --git a/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-light.png b/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-light.png new file mode 100644 index 0000000..10acc72 Binary files /dev/null and b/assets/screenshots/2024-08-14-github-organization-readme-badge-generator/markdown-badges-light.png differ diff --git a/assets/screenshots/2024-11-15-github-sub-issues-and-issue-types/sub-issues-issue-types-dark.png b/assets/screenshots/2024-11-15-github-sub-issues-and-issue-types/sub-issues-issue-types-dark.png new file mode 100644 index 0000000..c418432 Binary files /dev/null and b/assets/screenshots/2024-11-15-github-sub-issues-and-issue-types/sub-issues-issue-types-dark.png differ diff --git a/assets/screenshots/2024-11-15-github-sub-issues-and-issue-types/sub-issues-issue-types-light.png b/assets/screenshots/2024-11-15-github-sub-issues-and-issue-types/sub-issues-issue-types-light.png new file mode 100644 index 0000000..084a626 Binary files /dev/null and b/assets/screenshots/2024-11-15-github-sub-issues-and-issue-types/sub-issues-issue-types-light.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..1357b08 --- /dev/null +++ b/index.html @@ -0,0 +1,4 @@ +--- +layout: home +# Index page +--- diff --git a/jekyll-theme-chirpy.gemspec b/jekyll-theme-chirpy.gemspec new file mode 100644 index 0000000..9de7dd0 --- /dev/null +++ b/jekyll-theme-chirpy.gemspec @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = "jekyll-theme-chirpy" + spec.version = "7.1.1" + spec.authors = ["Cotes Chung"] + spec.email = ["cotes.chung@gmail.com"] + + spec.summary = "A minimal, responsive, and feature-rich Jekyll theme for technical writing." + spec.homepage = "https://github.com/cotes2020/jekyll-theme-chirpy" + spec.license = "MIT" + + spec.files = `git ls-files -z`.split("\x0").select { |f| + f.match(%r!^((_(includes|layouts|sass|(data\/(locales|origin)))|assets)\/|README|LICENSE)!i) + } + + spec.metadata = { + "bug_tracker_uri" => "https://github.com/cotes2020/jekyll-theme-chirpy/issues", + "documentation_uri" => "https://github.com/cotes2020/jekyll-theme-chirpy/#readme", + "homepage_uri" => "https://cotes2020.github.io/chirpy-demo", + "source_code_uri" => "https://github.com/cotes2020/jekyll-theme-chirpy", + "wiki_uri" => "https://github.com/cotes2020/jekyll-theme-chirpy/wiki", + "plugin_type" => "theme" + } + + spec.required_ruby_version = "~> 3.1" + + spec.add_runtime_dependency "jekyll", "~> 4.3" + spec.add_runtime_dependency "jekyll-paginate", "~> 1.1" + spec.add_runtime_dependency "jekyll-redirect-from", "~> 0.16" + spec.add_runtime_dependency "jekyll-seo-tag", "~> 2.8" + spec.add_runtime_dependency "jekyll-archives", "~> 2.2" + spec.add_runtime_dependency "jekyll-sitemap", "~> 1.4" + spec.add_runtime_dependency "jekyll-include-cache", "~> 0.2" + +end diff --git a/package.json b/package.json new file mode 100644 index 0000000..c7ba068 --- /dev/null +++ b/package.json @@ -0,0 +1,174 @@ +{ + "name": "jekyll-theme-chirpy", + "version": "7.1.1", + "description": "A minimal, responsive, and feature-rich Jekyll theme for technical writing.", + "repository": { + "type": "git", + "url": "git+https://github.com/cotes2020/jekyll-theme-chirpy.git" + }, + "author": "Cotes Chung", + "license": "MIT", + "since": 2019, + "bugs": { + "url": "https://github.com/cotes2020/jekyll-theme-chirpy/issues" + }, + "homepage": "https://github.com/cotes2020/jekyll-theme-chirpy/", + "scripts": { + "build": "concurrently npm:build:*", + "build:css": "purgecss -c purgecss.config.js", + "build:js": "rollup -c --bundleConfigAsCjs --environment BUILD:production", + "watch:js": "rollup -c --bundleConfigAsCjs -w", + "lint:scss": "stylelint _sass/**/*.scss", + "lint:fix:scss": "npm run lint:scss -- --fix", + "test": "npm run lint:scss", + "prepare": "husky" + }, + "dependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.3" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/preset-env": "^7.25.4", + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-terser": "^0.4.4", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/exec": "^6.0.3", + "@semantic-release/git": "^10.0.1", + "concurrently": "^9.0.1", + "conventional-changelog-conventionalcommits": "^8.0.0", + "husky": "^9.1.6", + "purgecss": "^6.0.0", + "rollup": "^4.21.3", + "semantic-release": "^24.1.1", + "stylelint": "^16.9.0", + "stylelint-config-standard-scss": "^13.1.0" + }, + "prettier": { + "trailingComma": "none" + }, + "browserslist": [ + "last 2 versions", + "> 0.2%", + "not dead" + ], + "commitlint": { + "rules": { + "body-max-line-length": [ + 0, + "always" + ] + } + }, + "stylelint": { + "extends": "stylelint-config-standard-scss", + "rules": { + "no-descending-specificity": null, + "shorthand-property-no-redundant-values": null, + "at-rule-no-vendor-prefix": null, + "property-no-vendor-prefix": null, + "selector-no-vendor-prefix": null, + "value-no-vendor-prefix": null, + "color-function-notation": "legacy", + "alpha-value-notation": "number", + "selector-not-notation": "simple", + "color-hex-length": "long", + "declaration-block-single-line-max-declarations": 3, + "scss/operator-no-newline-after": null, + "rule-empty-line-before": [ + "always", + { + "ignore": [ + "after-comment", + "first-nested" + ] + } + ], + "value-keyword-case": [ + "lower", + { + "ignoreProperties": [ + "/^\\$/" + ] + } + ], + "media-feature-range-notation": "prefix" + } + }, + "release": { + "branches": [ + "production" + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits" + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Improvements" + }, + { + "type": "refactor", + "section": "Changes", + "hidden": true + } + ] + } + } + ], + [ + "@semantic-release/changelog", + { + "changelogFile": "docs/CHANGELOG.md", + "changelogTitle": "# Changelog" + } + ], + [ + "@semantic-release/npm", + { + "npmPublish": false + } + ], + [ + "@semantic-release/exec", + { + "prepareCmd": "bash tools/release.sh --prepare", + "publishCmd": "bash tools/release.sh" + } + ], + [ + "@semantic-release/git", + { + "assets": [ + "docs", + "package.json", + "*.gemspec" + ], + "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" + } + ], + "@semantic-release/github" + ] + } +} diff --git a/purgecss.config.js b/purgecss.config.js new file mode 100644 index 0000000..de370de --- /dev/null +++ b/purgecss.config.js @@ -0,0 +1,23 @@ +const fs = require('fs'); +const DIST_PATH = '_sass/dist'; + +fs.rm(DIST_PATH, { recursive: true, force: true }, (err) => { + if (err) { + throw err; + } + + fs.mkdirSync(DIST_PATH); +}); + +module.exports = { + content: ['_includes/**/*.html', '_layouts/**/*.html', '_javascript/**/*.js'], + css: ['node_modules/bootstrap/dist/css/bootstrap.min.css'], + keyframes: true, + variables: true, + output: `${DIST_PATH}/bootstrap.css`, + // The `safelist` should be changed appropriately for future development + safelist: { + standard: [/^collaps/, /^w-/, 'shadow', 'border', 'kbd'], + greedy: [/^col-/, /tooltip/] + } +}; diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..19ba4da --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,74 @@ +import babel from '@rollup/plugin-babel'; +import terser from '@rollup/plugin-terser'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import fs from 'fs'; +import pkg from './package.json'; + +const SRC_DEFAULT = '_javascript'; +const SRC_PWA = `${SRC_DEFAULT}/pwa`; +const DIST = 'assets/js/dist'; + +const banner = `/*! + * ${pkg.name} v${pkg.version} | © ${pkg.since} ${pkg.author} | ${pkg.license} Licensed | ${pkg.homepage} + */`; + +const frontmatter = `---\npermalink: /:basename\n---\n`; + +const isProd = process.env.BUILD === 'production'; + +function cleanup() { + fs.rmSync(DIST, { recursive: true, force: true }); + console.log(`> Directory "${DIST}" has been cleaned.`); +} + +function insertFrontmatter() { + return { + name: 'insert-frontmatter', + generateBundle(_, bundle) { + for (const chunkOrAsset of Object.values(bundle)) { + if (chunkOrAsset.type === 'chunk') { + chunkOrAsset.code = frontmatter + chunkOrAsset.code; + } + } + } + }; +} + +function build(filename, { src = SRC_DEFAULT, jekyll = false } = {}) { + return { + input: `${src}/${filename}.js`, + output: { + file: `${DIST}/${filename}.min.js`, + format: 'iife', + name: 'Chirpy', + banner, + sourcemap: !isProd && !jekyll + }, + watch: { + include: `${src}/**` + }, + plugins: [ + babel({ + babelHelpers: 'bundled', + presets: ['@babel/env'], + plugins: ['@babel/plugin-transform-class-properties'] + }), + nodeResolve(), + isProd && terser(), + jekyll && insertFrontmatter() + ] + }; +} + +cleanup(); + +export default [ + build('commons'), + build('home'), + build('categories'), + build('page'), + build('post'), + build('misc'), + build('app', { src: SRC_PWA, jekyll: true }), + build('sw', { src: SRC_PWA, jekyll: true }) +]; diff --git a/tools/init.sh b/tools/init.sh new file mode 100755 index 0000000..2ad72ab --- /dev/null +++ b/tools/init.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# +# Init the environment for new user. + +set -eu + +# CLI Dependencies +CLI=("git" "npm") + +ACTIONS_WORKFLOW=pages-deploy.yml + +RELEASE_HASH=$(git log --grep="chore(release):" -1 --pretty="%H") + +# temporary file suffixes that make `sed -i` compatible with BSD and Linux +TEMP_SUFFIX="to-delete" + +_no_gh=false + +help() { + echo "Usage:" + echo + echo " bash /path/to/init [options]" + echo + echo "Options:" + echo " --no-gh Do not deploy to Github." + echo " -h, --help Print this help information." +} + +# BSD and GNU compatible sed +_sedi() { + regex=$1 + file=$2 + sed -i.$TEMP_SUFFIX -E "$regex" "$file" + rm -f "$file".$TEMP_SUFFIX +} + +_check_cli() { + for i in "${!CLI[@]}"; do + cli="${CLI[$i]}" + if ! command -v "$cli" &>/dev/null; then + echo "Command '$cli' not found! Hint: you should install it." + exit 1 + fi + done +} + +_check_status() { + if [[ -n $(git status . -s) ]]; then + echo "Error: Commit unstaged files first, and then run this tool again." + exit 1 + fi +} + +_check_init() { + if [[ $(git rev-parse HEAD^1) == "$RELEASE_HASH" ]]; then + echo "Already initialized." + exit 0 + fi +} + +check_env() { + _check_cli + _check_status + _check_init +} + +reset_latest() { + git reset --hard "$RELEASE_HASH" + git clean -fd + git submodule update --init --recursive +} + +init_files() { + if $_no_gh; then + rm -rf .github + else + ## Change the files of `.github/` + temp="$(mktemp -d)" + find .github/workflows -type f -name "*$ACTIONS_WORKFLOW*" -exec mv {} "$temp/$ACTIONS_WORKFLOW" \; + rm -rf .github && mkdir -p .github/workflows + mv "$temp/$ACTIONS_WORKFLOW" .github/workflows/"$ACTIONS_WORKFLOW" + rm -rf "$temp" + fi + + # Cleanup image settings in site config + _sedi "s/(^timezone:).*/\1/;s/(^.*cdn:).*/\1/;s/(^avatar:).*/\1/" _config.yml + + # remove the other files + rm -rf tools/init.sh tools/release.sh _posts/* + + # build assets + npm i && npm run build + + # track the CSS/JS output + _sedi "/.*\/dist$/d" .gitignore +} + +commit() { + git add -A + git commit -m "chore: initialize the environment" -q + echo -e "\n> Initialization successful!\n" +} + +main() { + check_env + reset_latest + init_files + commit +} + +while (($#)); do + opt="$1" + case $opt in + --no-gh) + _no_gh=true + shift + ;; + -h | --help) + help + exit 0 + ;; + *) + # unknown option + help + exit 1 + ;; + esac +done + +main diff --git a/tools/link-checker.sh b/tools/link-checker.sh new file mode 100755 index 0000000..c60058a --- /dev/null +++ b/tools/link-checker.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash + +# source: https://github.com/gaurav-nelson/github-action-markdown-link-check + +set -eu + +NC='\033[0m' # No Color +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' + +npm i -g markdown-link-check@3.10.3 +echo "::group::Debug information" +npm -g list --depth=1 +echo "::endgroup::" + +declare -a FIND_CALL +declare -a COMMAND_DIRS COMMAND_FILES +declare -a COMMAND_FILES + +USE_QUIET_MODE="no" +USE_VERBOSE_MODE="no" +CONFIG_FILE="link-checker.json" +FOLDER_PATH="../_posts" +MAX_DEPTH="-1" +CHECK_MODIFIED_FILES="no" +BASE_BRANCH="main" +FILE_EXTENSION=".md" +FILE_PATH="" + +echo "{}" > $CONFIG_FILE + +if [ -f "$CONFIG_FILE" ]; then + echo -e "${BLUE}Using markdown-link-check configuration file: ${YELLOW}$CONFIG_FILE${NC}" +else + echo -e "${BLUE}Cannot find ${YELLOW}$CONFIG_FILE${NC}" + echo -e "${YELLOW}NOTE: See https://github.com/tcort/markdown-link-check#config-file-format to know more about" + echo -e "customizing markdown-link-check by using a configuration file.${NC}" +fi + +FOLDERS="" +FILES="" + +echo -e "${BLUE}USE_QUIET_MODE: $USE_QUIET_MODE${NC}" +echo -e "${BLUE}USE_VERBOSE_MODE: $USE_VERBOSE_MODE${NC}" +echo -e "${BLUE}FOLDER_PATH: $FOLDER_PATH${NC}" +echo -e "${BLUE}MAX_DEPTH: $MAX_DEPTH${NC}" +echo -e "${BLUE}CHECK_MODIFIED_FILES: $CHECK_MODIFIED_FILES${NC}" +echo -e "${BLUE}FILE_EXTENSION: $FILE_EXTENSION${NC}" +echo -e "${BLUE}FILE_PATH: $FILE_PATH${NC}" + +handle_dirs () { + + IFS=', ' read -r -a DIRLIST <<< "$FOLDER_PATH" + + for index in "${!DIRLIST[@]}" + do + if [ ! -d "${DIRLIST[index]}" ]; then + echo -e "${RED}ERROR [✖] Can't find the directory: ${YELLOW}${DIRLIST[index]}${NC}" + rm $CONFIG_FILE + exit 2 + fi + COMMAND_DIRS+=("${DIRLIST[index]}") + done + FOLDERS="${COMMAND_DIRS[*]}" + +} + +handle_files () { + + IFS=', ' read -r -a FILELIST <<< "$FILE_PATH" + + for index in "${!FILELIST[@]}" + do + if [ ! -f "${FILELIST[index]}" ]; then + echo -e "${RED}ERROR [✖] Can't find the file: ${YELLOW}${FILELIST[index]}${NC}" + exit 2 + fi + if [ "$index" == 0 ]; then + COMMAND_FILES+=("-wholename ${FILELIST[index]}") + else + COMMAND_FILES+=("-o -wholename ${FILELIST[index]}") + fi + done + FILES="${COMMAND_FILES[*]}" + +} + +check_errors () { + + if [ -e error.txt ] ; then + if grep -q "ERROR:" error.txt; then + echo -e "${YELLOW}=========================> MARKDOWN LINK CHECK <=========================${NC}" + cat error.txt + printf "\n" + echo -e "${YELLOW}=========================================================================${NC}" + rm $CONFIG_FILE + exit 113 + else + echo -e "${YELLOW}=========================> MARKDOWN LINK CHECK <=========================${NC}" + printf "\n" + echo -e "${GREEN}[✔] All links are good!${NC}" + printf "\n" + echo -e "${YELLOW}=========================================================================${NC}" + fi + else + echo -e "${GREEN}All good!${NC}" + fi + +} + +add_options () { + + if [ -f "$CONFIG_FILE" ]; then + FIND_CALL+=('--config' "${CONFIG_FILE}") + fi + + if [ "$USE_QUIET_MODE" = "yes" ]; then + FIND_CALL+=('-q') + fi + + if [ "$USE_VERBOSE_MODE" = "yes" ]; then + FIND_CALL+=('-v') + fi + +} + +check_additional_files () { + + if [ -n "$FILES" ]; then + if [ "$MAX_DEPTH" -ne -1 ]; then + FIND_CALL=('find' '.' '-type' 'f' '(' ${FILES} ')' '-not' '-path' './node_modules/*' '-maxdepth' "${MAX_DEPTH}" '-exec' 'markdown-link-check' '{}') + else + FIND_CALL=('find' '.' '-type' 'f' '(' ${FILES} ')' '-not' '-path' './node_modules/*' '-exec' 'markdown-link-check' '{}') + fi + + add_options + + FIND_CALL+=(';') + + set -x + "${FIND_CALL[@]}" &>> error.txt + set +x + + fi + +} + +if [ -z "$FILE_EXTENSION" ]; then + FOLDERS="." +else + handle_dirs +fi + +if [ -n "$FILE_PATH" ]; then + handle_files +fi + +if [ "$CHECK_MODIFIED_FILES" = "yes" ]; then + + echo -e "${BLUE}BASE_BRANCH: $BASE_BRANCH${NC}" + + git config --global --add safe.directory '*' + + git fetch origin "${BASE_BRANCH}" --depth=1 > /dev/null + MASTER_HASH=$(git rev-parse origin/"${BASE_BRANCH}") + + FIND_CALL=('markdown-link-check') + + add_options + + FOLDER_ARRAY=(${FOLDER_PATH//,/ }) + mapfile -t FILE_ARRAY < <( git diff --name-only --diff-filter=AM "$MASTER_HASH" -- "${FOLDER_ARRAY[@]}") + + for i in "${FILE_ARRAY[@]}" + do + if [ "${i##*.}" == "${FILE_EXTENSION#.}" ]; then + FIND_CALL+=("${i}") + COMMAND="${FIND_CALL[*]}" + $COMMAND &>> error.txt || true + unset 'FIND_CALL[${#FIND_CALL[@]}-1]' + fi + done + + check_additional_files + + check_errors + +else + + if [ "$MAX_DEPTH" -ne -1 ]; then + FIND_CALL=('find' ${FOLDERS} '-name' '*'"${FILE_EXTENSION}" '-not' '-path' './node_modules/*' '-maxdepth' "${MAX_DEPTH}" '-exec' 'markdown-link-check' '{}') + else + FIND_CALL=('find' ${FOLDERS} '-name' '*'"${FILE_EXTENSION}" '-not' '-path' './node_modules/*' '-exec' 'markdown-link-check' '{}') + fi + + add_options + + FIND_CALL+=(';') + + set -x + "${FIND_CALL[@]}" &>> error.txt + set +x + + check_additional_files + + check_errors + +fi diff --git a/tools/release.sh b/tools/release.sh new file mode 100755 index 0000000..522c892 --- /dev/null +++ b/tools/release.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# Requires: Git, NPM and RubyGems + +set -eu + +opt_pre=false # option for bump gem version +opt_pkg=false # option for building gem package + +MAIN_BRANCH="master" +RELEASE_BRANCH="production" + +GEM_SPEC="jekyll-theme-chirpy.gemspec" +NODE_SPEC="package.json" +CHANGELOG="docs/CHANGELOG.md" +CONFIG="_config.yml" + +CSS_DIST="_sass/dist" +JS_DIST="assets/js/dist" + +FILES=( + "$GEM_SPEC" + "$NODE_SPEC" + "$CHANGELOG" + "$CONFIG" +) + +TOOLS=( + "git" + "npm" + "gem" +) + +help() { + echo -e "A tool to release new version Chirpy gem.\nThis tool will:" + echo " 1. Build a new gem and publish it to RubyGems.org" + echo " 2. Merge the release branch into the default branch" + echo + echo "Usage:" + echo " bash $0 [options]" + echo + echo "Options:" + echo " --prepare Preparation for release" + echo " -p, --package Build a gem package only, for local packaging in case of auto-publishing failure" + echo " -h, --help Display this help message" +} + +_check_cli() { + for i in "${!TOOLS[@]}"; do + cli="${TOOLS[$i]}" + if ! command -v "$cli" &>/dev/null; then + echo "> Command '$cli' not found!" + exit 1 + fi + done +} + +_check_git() { + $opt_pre || ( + # ensure that changes have been committed + if [[ -n $(git status . -s) ]]; then + echo "> Abort: Commit the staged files first, and then run this tool again." + exit 1 + fi + ) + + $opt_pkg || ( + if [[ "$(git branch --show-current)" != "$RELEASE_BRANCH" ]]; then + echo "> Abort: Please run the tool in the '$RELEASE_BRANCH' branch." + exit 1 + fi + ) +} + +_check_src() { + for i in "${!FILES[@]}"; do + _src="${FILES[$i]}" + if [[ ! -f $_src && ! -d $_src ]]; then + echo -e "> Error: Missing file \"$_src\"!\n" + exit 1 + fi + done +} + +init() { + _check_cli + _check_git + _check_src + echo -e "> npm install\n" + npm i +} + +## Bump new version to gem-spec file +_bump_version() { + _version="$(grep '"version":' "$NODE_SPEC" | sed 's/.*: "//;s/".*//')" + sed -i "s/[[:digit:]]\+\.[[:digit:]]\+\.[[:digit:]]\+/$_version/" "$GEM_SPEC" + echo "> Bump gem version to $_version" +} + +_improve_changelog() { + # Replace multiple empty lines with a single empty line + sed -i '/^$/N;/^\n$/D' "$CHANGELOG" + # Escape left angle brackets of HTML tag in the changelog as they break the markdown structure. e.g., '
' + sed -i -E 's/\s(<[a-z])/ \\\1/g' "$CHANGELOG" +} + +prepare() { + _bump_version + _improve_changelog +} + +## Build a Gem package +build_gem() { + # Remove unnecessary theme settings + sed -i -E "s/(^timezone:).*/\1/;s/(^cdn:).*/\1/;s/(^avatar:).*/\1/" $CONFIG + rm -f ./*.gem + + npm run build + # add CSS/JS distribution files to gem package + git add "$CSS_DIST" "$JS_DIST" -f + + echo -e "\n> gem build $GEM_SPEC\n" + gem build "$GEM_SPEC" + + echo -e "\n> Resume file changes ...\n" + git reset + git checkout . +} + +# Push the gem to RubyGems.org (using $GEM_HOST_API_KEY) +push_gem() { + gem push ./*.gem +} + +## Merge the release branch into the default branch +merge() { + git fetch origin "$MAIN_BRANCH" + git checkout -b "$MAIN_BRANCH" origin/"$MAIN_BRANCH" + + git merge --no-ff --no-edit "$RELEASE_BRANCH" || ( + git merge --abort + echo -e "\n> Conflict detected. Aborting merge.\n" + exit 0 + ) + + git push origin "$MAIN_BRANCH" +} + +main() { + init + + if $opt_pre; then + prepare + exit 0 + fi + + build_gem + $opt_pkg && exit 0 + push_gem + merge +} + +while (($#)); do + opt="$1" + case $opt in + --prepare) + opt_pre=true + shift + ;; + -p | --package) + opt_pkg=true + shift + ;; + -h | --help) + help + exit 0 + ;; + *) + # unknown option + help + exit 1 + ;; + esac +done + +main diff --git a/tools/run.sh b/tools/run.sh new file mode 100755 index 0000000..0efc452 --- /dev/null +++ b/tools/run.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Run jekyll serve and then launch the site + +prod=false +command="bundle exec jekyll s -l" +host="127.0.0.1" + +help() { + echo "Usage:" + echo + echo " bash /path/to/run [options]" + echo + echo "Options:" + echo " -H, --host [HOST] Host to bind to." + echo " -p, --production Run Jekyll in 'production' mode." + echo " -h, --help Print this help information." +} + +while (($#)); do + opt="$1" + case $opt in + -H | --host) + host="$2" + shift 2 + ;; + -p | --production) + prod=true + shift + ;; + -h | --help) + help + exit 0 + ;; + *) + echo -e "> Unknown option: '$opt'\n" + help + exit 1 + ;; + esac +done + +command="$command -H $host" + +if $prod; then + command="JEKYLL_ENV=production $command" +fi + +if [ -e /proc/1/cgroup ] && grep -q docker /proc/1/cgroup; then + command="$command --force_polling" +fi + +echo -e "\n> $command\n" +eval "$command" diff --git a/tools/test.sh b/tools/test.sh new file mode 100755 index 0000000..331de1c --- /dev/null +++ b/tools/test.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Build and test the site content +# +# Requirement: html-proofer, jekyll +# +# Usage: See help information + +set -eu + +SITE_DIR="_site" + +_config="_config.yml" + +_baseurl="" + +help() { + echo "Build and test the site content" + echo + echo "Usage:" + echo + echo " bash $0 [options]" + echo + echo "Options:" + echo ' -c, --config "" Specify config file(s)' + echo " -h, --help Print this information." +} + +read_baseurl() { + if [[ $_config == *","* ]]; then + # multiple config + IFS="," + read -ra config_array <<<"$_config" + + # reverse loop the config files + for ((i = ${#config_array[@]} - 1; i >= 0; i--)); do + _tmp_baseurl="$(grep '^baseurl:' "${config_array[i]}" | sed "s/.*: *//;s/['\"]//g;s/#.*//")" + + if [[ -n $_tmp_baseurl ]]; then + _baseurl="$_tmp_baseurl" + break + fi + done + + else + # single config + _baseurl="$(grep '^baseurl:' "$_config" | sed "s/.*: *//;s/['\"]//g;s/#.*//")" + fi +} + +main() { + # clean up + if [[ -d $SITE_DIR ]]; then + rm -rf "$SITE_DIR" + fi + + read_baseurl + + # build + JEKYLL_ENV=production bundle exec jekyll b \ + -d "$SITE_DIR$_baseurl" -c "$_config" + + # test + bundle exec htmlproofer "$SITE_DIR" \ + --disable-external \ + --ignore-urls "/^http:\/\/127.0.0.1/,/^http:\/\/0.0.0.0/,/^http:\/\/localhost/" +} + +while (($#)); do + opt="$1" + case $opt in + -c | --config) + _config="$2" + shift + shift + ;; + -h | --help) + help + exit 0 + ;; + *) + # unknown option + help + exit 1 + ;; + esac +done + +main