diff --git a/.gitattributes b/.gitattributes index 42e50a5cd2b..70e292e6084 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,15 @@ # Auto detect text files and perform LF normalization * text=auto -# TODO(@lowlighter): linguist-attributes \ No newline at end of file +# Hidden files +.github/** -linguist-detectable + +# Documentation +CODE_OF_CONDUCT.md linguist-documentation +CONTRIBUTING.md linguist-documentation +SECURITY.md linguist-documentation +README.md linguist-documentation +LICENSE linguist-documentation + +# Generated files +deno.lock linguist-generated \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f531ce47253..d5101586609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,29 @@ -name: Continuous integration +name: Metrics on: push: branches: - - v4 - pull_request: - branches: - - v4 + - v4-dev + #pull_request: + # branches: + # - v4-dev + # paths-ignore: + # - "**/*.md" jobs: + # Lint, format, build and test testing: runs-on: ubuntu-latest strategy: matrix: task: - - lint - - fmt --check - - test + - ci:lint + - ci:test + fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: denoland/setup-deno@v1 - - run: deno task ${{ matrix.task }} + - run: deno task make ${{ matrix.task }} + + # GitHub CodeQL analysis analyze: runs-on: ubuntu-latest needs: @@ -31,14 +36,46 @@ jobs: fail-fast: false matrix: language: - - javascript-typescript + - typescript steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} - queries: security-and-quality + config: | + queries: + - uses: security-and-quality + paths-ignore: + - source/run/serve/imported.ts - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + # Build and push Docker image + docker: + if: github.ref == 'refs/heads/v4-dev' + runs-on: ubuntu-latest + needs: + - analyze + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + if: github.event_name == 'push' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - uses: docker/build-push-action@v5 + with: + context: . + tags: ghcr.io/${{ github.repository }}:v4 + push: ${{ github.event_name == 'push' }} + + # Test GitHub Action + github-action: + runs-on: ubuntu-latest + needs: + - docker + steps: + - uses: lowlighter/metrics@v4-dev diff --git a/.gitignore b/.gitignore index 98bb56289f4..966e3b5b425 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,12 @@ +.deno-make.json +.cache .coverage +.env +.secrets .test +.kv +.kv-* node_modules metrics.config.yml - -# TODO(@lowlighter): remove after migration -.legacy \ No newline at end of file +settings.json +source/run/serve/static/app.js \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 105677a69c7..e8a3911fce3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1 @@ # 🏗ī¸ We're working on it ! - - diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..6c1b6f99df7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# Metrics docker image +FROM alpine:3.18 +RUN apk upgrade --no-cache --available + +# Install sudo +RUN apk add --update --no-cache sudo \ + && echo 'metrics ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/metrics + +# Install licensed +RUN apk add --no-cache ruby \ + && apk add --no-cache --virtual .licensed ruby-dev make cmake g++ heimdal-dev \ + && gem install licensed \ + && apk del .licensed \ + && licensed --version + +# Install chromium +ENV CHROME_BIN /usr/bin/chromium-browser +ENV CHROME_PATH /usr/lib/chromium/ +ENV CHROME_EXTRA_FLAGS "--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage" +RUN apk add --no-cache chromium ttf-freefont font-noto-emoji \ + && apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community font-wqy-zenhei \ + && chromium --version + +# Install docker +RUN apk add --update --no-cache docker-cli \ + && addgroup docker \ + && docker --version + +# Install deno +ENV DENO_INSTALL / +ENV DENO_NO_UPDATE_CHECK true +ENV DENO_VERSION 1.38.3 +ENV GLIBC_VERSION 2.34-r0 +RUN apk add --no-cache --virtual .deno curl wget unzip \ + && wget --no-hsts --quiet --output-document /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub \ + && wget --no-hsts --quiet https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-${GLIBC_VERSION}.apk \ + && wget --no-hsts --quiet https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-bin-${GLIBC_VERSION}.apk \ + && wget --no-hsts --quiet https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-i18n-${GLIBC_VERSION}.apk \ + && mv /etc/nsswitch.conf /etc/nsswitch.conf.bak \ + && apk add --no-cache --force-overwrite glibc-${GLIBC_VERSION}.apk glibc-bin-${GLIBC_VERSION}.apk glibc-i18n-${GLIBC_VERSION}.apk \ + && mv /etc/nsswitch.conf.bak /etc/nsswitch.conf \ + && (/usr/glibc-compat/bin/localedef --force --inputfile POSIX --charmap UTF-8 "$LANG" || true) \ + && (echo "export LANG=$LANG" > /etc/profile.d/locale.sh) \ + && rm /etc/apk/keys/sgerrand.rsa.pub glibc-${GLIBC_VERSION}.apk glibc-bin-${GLIBC_VERSION}.apk glibc-i18n-${GLIBC_VERSION}.apk \ + && apk del glibc-i18n \ + && (curl -fsSL https://deno.land/x/install/install.sh | sh) \ + && apk del .deno \ + && deno upgrade --version ${DENO_VERSION} \ + && deno --version + +# Install lighthouse +RUN apk add --no-cache npm \ + && npm install --global lighthouse \ + && lighthouse --version + +# General configuration +RUN apk add --no-cache git \ + && adduser --system metrics \ + && addgroup metrics docker + +# Metrics +USER metrics +WORKDIR /metrics +ENV TZ Europe/Paris +ENV TMP /tmp +COPY source /metrics/source +COPY deno.jsonc /metrics/deno.jsonc +COPY LICENSE /metrics/LICENSE +RUN deno task make cache +RUN deno task make get:browser +ENTRYPOINT [ "deno", "task", "make", "run", "github-action" ] \ No newline at end of file diff --git a/README.md b/README.md index 391dc447c8c..0d75f31d52b 100644 --- a/README.md +++ b/README.md @@ -1,473 +1,3 @@ # 🏗ī¸ We're working on it ! - - -See [#1533](https://github.com/lowlighter/metrics/discussions/1533) - -## ✈ī¸ Migration guide - -> ℹī¸ This is still subject to change, see this guide more as a "pre-release note"/"roadmap progress". It may be used later on to create a migration tool from v3 to v4. Plugins not yet listed are not -> yet migrated. This guide will be updated later on - -### Migration and progression - -- 📝 **Todo-list before pre-release** - - [ ] Engine - - [x] Rendering - - [x] Inputs parsing - - [x] Outputs parsing - - [x] Plugin framework - - [x] Processor framework - - [ ] Docs auto-generation - - [ ] GitHub Action - - [x] Implement `publish.gist` - - [x] Implement `publish.file` - - [ ] Implement `publish.git` (almost finished, needs to handle the PR merge) - - [ ] Docker image - - [ ] Web server - - [ ] Config crafter (big draft) - - [x] OAuth support - - [ ] Vercel deployment (next priority) - - [ ] Plugins - - [x] A simple plugin - - [x] A plugin that requires puppeteer - - [x] A plugin that requires an external library - - [x] A plugin that requires GraphQL API - - [x] A plugin that requires paginated GraphQL queries - - [x] A plugin that requires REST API - - [ ] A plugin that requires paginated REST API - - [ ] A plugin that requires executing a raw command - - [ ] A plugin that requires rendering markdown - - [ ] A plugin that requires rendering d3 graphs - - [ ] A plugin that requires rendering gif -- đŸ“Ļ **Interfaces** - - 🌐 **Web server** - - â˜Ŗī¸ _(Working proof of concept)_ - - ❗ Syntax was unified with the _GitHub Action_ one - - ✨ YAML syntax is "lax" and does not require spaces after each colon (e.g. `plugins:[{id:introduction}]` and `plugins: [{id: introduction}]` are both supported) - - ✨ Append `.svg`, `.png`, `.jpg`/`.jpeg`, `.webp`, `.json`, `.html`, `.pdf`, `.txt` or `.text` for implicit conversion (e.g. `metrics.test/octocat.json`) - - ❓ _Metrics Insights_ will probably be "removed", and maybe converted to a preset/template instead, which will make it on-par with all plugins, as `.html` format will be directly supported - (basically since the SVG is already HTML, it'll just change the MIME type) - - ⚙ī¸ **GitHub Action** - - â˜Ŗī¸ _(Not yet started)_ - - ⌨ī¸ **CLI** - - â˜Ŗī¸ _(Not yet started)_ -- 🧩 **Plugins** - - 📅 **Isometric commit calendar** - - ❗ _This plugin was merged with `calendar` plugin_ - - ➡ī¸ `calendar.args.view: isometric` - - ❗ `plugin_isocalendar: yes` ➡ī¸ `plugins: [{id: calendar}]` with `args` - - ❗ `plugin_isocalendar_duration: half-year` ➡ī¸ `calendar.args.range: last-180-days` - - ❗ `plugin_isocalendar_duration: full-year` ➡ī¸ `calendar.args.range: last-365-days` - - 📆 **Commit calendar** - - ✨ Merged `isocalendar` and `calendar` plugins, which means that both plugins now have same level of features - - ✨ `isocalendar` can now display multiple years and a specific year - - ✨ `calendar` can now display additional stats (such as commits per day, streaks, etc.) - - ✨ `calendar.args.view` can now be set to `isometric` or `top-down` - - ✨ `calendar.args.range` can now be set to `last-180-days`, `last-365-days`, a specific year or a custom range - - ✨ `calendar.args.range.from` can now be set to `registration`, `-n` years relative to `calendar.args.range.to` or a specific year - - ✨ `calendar.args.range.to` can now be set to `current-year` or a specific year - - ✨ `calendar.args.colors` can now be set to `auto`, `halloween` or `winter` - - ❗ `plugin_calendar: yes` ➡ī¸ `plugins: [{id: calendar}]` with `args` - - ❗ `plugin_calendar_limit: 0` ➡ī¸ `calendar.args.range: {from: registration, to: current-year}` - - ❗ `plugin_calendar_limit: (n > 0)` ➡ī¸ `calendar.args.range: {from: (-n), to: current-year}` - - ❌ `plugin_calendar_limit: (n < 0)` ➡ī¸ Use `calendar.args.range.from` with a specific year - - đŸŽĢ **Gists** - - 🐞 Fine-grained tokens always returns `null` data - - ❗ `plugin_gists: yes` ➡ī¸ `plugins: [{id: gists}]` - - ✨ `gists.args.forks` can now be configured - - ✨ `gists.args.visibility` can now be set to `public` or `all` - - 🙋 **Introduction** - - ❗ `plugin_introduction: yes` ➡ī¸ `plugins: [{id: introduction}]` - - ❌ `plugin_introduction_title` ➡ī¸ `processors: [{id: inject.style, args: {style: ".introduction .title { display: none }"}}]` - - đŸ—ŧ **Rss feed** - - ❗ `plugin_rss: yes` ➡ī¸ `plugins: [{id: rss}]` with `args` - - ❗ `plugin_rss_source` ➡ī¸ `rss.args.feed` - - ❗ `plugin_rss_limit` ➡ī¸ `rss.args.limit` - - ❗ `plugin_rss_limit: 0` ➡ī¸ `rss.args.limit: null` - - ✨ `rss.args.limit` no longer has an upper limit (lower limit was changed to `1`) - - 📸 **Website screenshot** - - ❗ _This plugin was renamed `webscrap` and is now part of official plugins_ - - ➡ī¸ Use `webscrap` plugin - - ❗ `plugin_screenshot: yes` ➡ī¸ `plugins: [{id: webscrap}]` with `args` - - ❗ `plugin_screenshot_url` ➡ī¸ `webscrap.args.url` - - ❗ `plugin_screenshot_selector` ➡ī¸ `webscrap.args.select` - - ❗ `plugin_screenshot_mode` ➡ī¸ `webscrap.args.mode` - - ❗ `plugin_screenshot_viewport` ➡ī¸ `webscrap.args.viewport.width` and `webscrap.args.viewport.height` - - ❗ `plugin_screenshot_wait` ➡ī¸ `webscrap.args.wait` - - ❗ `plugin_screenshot_background` ➡ī¸ `webscrap.args.background` - - 📸 **Webscrap** - - ✨ Added `screenshot` as part of official plugins - - 💭 **GitHub Community Support** - - ❌ Removed as it was already deprecated - - 🧱 **Core** - - â˜Ŗī¸ _(Will be more detailed once API is finalized)_ - - ✨ Context - - ❗ `token` ➡ī¸ `plugins[].token` - - ❗ `user` ➡ī¸ `plugins[].handle` with `plugins[].entity: user` or `plugins[].entity: organization` - - ❗ `repo` ➡ī¸ `plugins[].handle` with `plugins[].entity: repository` - - ❗ `template` ➡ī¸ `plugins[].template` - - ❗ `retries` ➡ī¸ `plugins[].retries.attempts` - - ❗ `retries_delay` ➡ī¸ `plugins[].retries.delay` - - ❗ `github_api_rest` ➡ī¸ `plugins[].api` - - ❗ `github_api_graphql` ➡ī¸ `plugins[].api` - - ❗ `plugins_errors_fatal` ➡ī¸ `plugins[].fatal` - - ❗ `config_timezone` ➡ī¸ `plugins[].timezone` - - ❗ `debug` ➡ī¸ `plugins[].logs` - - ✨ Configure verbosity with `none`, `error`, `warn`, `info`, `message`, `debug`, `trace` - - ❗ `committer_gist` ➡ī¸ `processors: [{id: publish.gist}}]` with `args.gist` and `args.filename` - - ❌ `committer_token` ➡ī¸ `plugins[].token` (publish transforms inherits the plugin context) - - ❗ `committer_branch` ➡ī¸ `processors: [{id: publish.git}}]` with `args.commit.branch`, `args.commit.base`, `args.pullrequest.branch` and `args.pullrequest.base` - - ❗ `committer_message` ➡ī¸ `processors: [{id: publish.git}}]` with `args.commit.message` or `args.pullrequest.message` - - ❗ `filename` ➡ī¸ `processors[]` with `args.filepath` - - ❗ `config_twemoji` ➡ī¸ `processors: [{id: render.twemojis}]` - - ❗ `config_gemoji` ➡ī¸ `processors: [{id: render.gemojis}]` - - ❗ `config_octicon` ➡ī¸ `processors: [{id: render.octicons}]` - - ❗ `extras_js` ➡ī¸ `processors: [{id: inject.script}]` with `args.script` - - ❗ `extras_css` ➡ī¸ `processors: [{id: inject.style}]` with `args.style` - - ❗ `optimize` ➡ī¸ `processors: [{id: optimize.svg}]`, `processors: [{id: optimize.xml}]`, `processors: [{id: optimize.css}]` - - ❗ `config_output` ➡ī¸ `processors: [{id: render}]` with `args.format` - - ❗ `config_order` ➡ī¸ Plugins order is honored from `plugins[]` - - ❌ `config_display` ➡ī¸ `processors: [{id: inject.style}]` with `args.style`, or a custom template - - ❌ `config_animations` ➡ī¸ `processors: [{id: inject.style}]` with `args.style`, or a custom template - - ❌ `config_padding` ➡ī¸ - - ❗ `debug_print` ➡ī¸ `processors: [{id: publish.console}]` - - ❌ `debug_flags` - - ❗ `debug_flags: --halloween` ➡ī¸ `calendar.args.colors: halloween` - - ❗ `debug_flags: --winter` ➡ī¸ `calendar.args.colors: winter` - - ❌ `setup_community_templates` ➡ī¸ `plugins[].template: https://...` - - ❌ `query` ➡ī¸ `plugins[].template: https://...?params` - - ❌ `dryrun` ➡ī¸ Don't put any publisher processor - - ❌ `experimental_features` - - ❌ `verify` -- đŸĒ„ **Processors** - - đŸ§Ē **Assertions** - - ✨ Added processor to test assertions - - 🔩 **Inject raw content** - - ✨ Added processor to inject raw HTML content - - 🔩 **Inject JavaScript** - - ✨ Added processor to inject and execute JS - - 🔩 **Inject CSS** - - ✨ Added processor to inject CSS - - 🧹 **Optimize CSS** - - ✨ Added processor to optimize CSS - - 🧹 **Optimize SVG** - - ✨ Added processor to optimize SVG - - 🧹 **Optimize XML** - - ✨ Added processor to optimize XML - - 📮 **Publish to console** - - ✨ Added processor to publish content to console - - 📮 **Publish to local file** - - ✨ Added processor to publish content to local file - - 📮 **Publish to GitHub Gist** - - ✨ Added processor to publish content to GitHub Gist - - 📮 **Publish to GitHub repository** - - ✨ Added processor to publish content to GitHub repositories - - ✨ `args.commit.branch`, `args.commit.base`, `args.pullrequest.base`, `args.pullrequest.base` can now be configured with more granularity - - ✨ `args.pullrequest.title` and `args.pullrequest.message` can now be configured - - 🎨 **Render image** - - ✨ Added processor to render image - - ✨ Can output to `svg`, `png`, `jpeg`, `webp`, `json`, `html`, `markdown` or `pdf` - - 🖌ī¸ **Render Twemojis** - - ✨ Added processor to render [Twemojis](https://twemoji.twitter.com) - - 🖌ī¸ **Render GitHub emojis** - - ✨ Added processor to render GitHub emojis - - 🖌ī¸ **Render GitHub octicons** - - ✨ Added processor to render [GitHub Octicons](https://primer.style/design/foundations/icons) -- đŸ’ģ **Repository and maintenance** - - ➕ Migration to [deno](https://deno.com) and TypeScript - - ➕ Unified linting and formatting - - ➕ Minimal execution flags for more security and data leaking prevention - - ➕ Extended unit testing with coverage - - ➕ Improved data mocking which does not requiring directly editing prototypes - - ➕ Testing EYOF using the engine itself - -### Legend - -- â˜Ŗī¸ Experimental feature -- ✨ New feature -- ❌ Removed feature -- ❗ Edited feature -- ❓ Unsure feature -- ➡ī¸ Migration path -- 🐞 Known issue that will be fixed before release - - +See [#1573](https://github.com/lowlighter/metrics/issues/1573) diff --git a/action.yml b/action.yml new file mode 100644 index 00000000000..479e4080ca9 --- /dev/null +++ b/action.yml @@ -0,0 +1,45 @@ +name: Metrics embed +author: lowlighter +description: An infographics generator with 40+ plugins and 300+ options to display stats about your GitHub account! +branding: + icon: user-check + color: gray-dark + +inputs: + docker_image: + description: Docker image to use + default: ghcr.io/lowlighter/metrics:v4 + required: true + config: + description: Configuration file + default: metrics.config.yml + required: true + +outputs: + result: + description: Result of the action + +runs: + using: composite + steps: + - uses: actions/checkout@v3 + - uses: denoland/setup-deno@v1 + - shell: bash + env: + INPUTS: ${{ toJson(inputs) }} + run: | + echo "::group::Metrics docker image setup" + echo "GitHub action: $(echo '${{ github.action }}' | tr '_' '/')" + cd ${{ github.action_path }} + DOCKER_IMAGE="${{ inputs.docker_image }}" + if [ -f "$DOCKER_IMAGE" ]; then + DOCKER_LOCAL=metrics:local + echo "Using local docker image: $DOCKER_IMAGE" + deno task make docker --tag $DOCKER_LOCAL --file $DOCKER_IMAGE + DOCKER_IMAGE=$DOCKER_LOCAL + else + echo "Pulling remote docker image: $DOCKER_IMAGE" + docker pull $DOCKER_IMAGE + fi + echo "::endgroup::" + docker run --tty --env-file <(env | grep -E 'GITHUB|CI|TZ') --env INPUTS --rm --volume ${{ github.workspace }}:/workspace --volume /metrics_renders:/metrics_renders $DOCKER_IMAGE run diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 00000000000..97af9318fea --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,322 @@ +{ + "name": "metrics", + "version": "4.0.0", + "imports": { + "std/": "https://deno.land/std@0.205.0/", + "x/": "https://deno.land/x/", + "y/": "https://esm.sh/", + "gh/": "https://esm.sh/gh/", + "@engine/": "./source/engine/", + "@plugins/": "./source/plugins/", + "@processors/": "./source/processors/", + "@run/": "./source/run/" + }, + "lint": { + "rules": { + "include": [ + "ban-untagged-todo", + "default-param-last", + "eqeqeq", + "no-const-assign", + "no-eval", + "no-sparse-arrays", + "no-sync-fn-in-async-fn", + "no-throw-literal" + ] + }, + "exclude": [ + "source/run/serve/static/app.js" + ] + }, + "fmt": { + "lineWidth": 200, + "semiColons": false, + "exclude": [ + ".coverage", + ".deno-make.json", + "source/run/serve/static/app.js", + "CODE_OF_CONDUCT.md" + ] + }, + "tasks": { + "make": "deno run --allow-env --allow-read --allow-write=.deno-make.json --allow-run=deno https://deno.land/x/make@1.2.0/mod.ts $0" + }, + "+tasks": { + "run": { + "description": "🚀 Run metrics", + "task": [ + "deno task make get:browser &&", + "export CHROME_BIN=$(deno task make get:browser) &&", + "export CACHE_DIRECTORY=$(deno task make get:cache) &&", + "deno run source/run/mod.ts $<*>" + ], + "deno": { + "run": { + "unstable": ["kv", "http"], + "permissions": { + "net": [ + // Server bindings + "0.0.0.0", + "127.0.0.1", + "localhost", + // Packages + "esm.sh", + "deno.land/x", + "deno.land/std", + "plugins.dprint.dev/typescript-0.88.4.wasm", + "raw.githubusercontent.com/github-linguist/linguist/master/lib/linguist/languages.yml", + // GitHub API + "api.github.com", + // Github OAuth + "github.com/login/oauth", + // plugins/rss + "news.ycombinator.com/rss", + // processors/render.gemojis + "api.github.com/emojis", + // processors/render.twemojis + "cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets" + ], + "run": [ + "docker", + "$CHROME_BIN" + ], + "read": [ + "$PWD", + "$TMP", + "$CACHE_DIRECTORY", + ".cache", + "deno.jsonc", + "metrics.config.yml" + ], + "write": [ + "$TMP", + "$HOME/.config/chromium/SingletonLock" + ], + "env": true + } + } + } + }, + "test": { + "description": "đŸ§Ē Run tests and collect coverage", + "task": [ + "deno task make get:browser &&", + "export CHROME_BIN=$(deno task make get:browser) &&", + "rm .coverage -rf &&", + "deno test source" + ], + "deno": { + "test": { + "doc": true, + "seed": 0, + "coverage": ".coverage", + "traceOps": true, + "unstable": ["kv", "http"], + "modules": { + "check": false + }, + "permissions": { + "net": [ + // Server bindings + "0.0.0.0", + "127.0.0.1", + "localhost", + // Packages + "esm.sh", + "deno.land/x", + "deno.land/std", + "plugins.dprint.dev/typescript-0.88.4.wasm", + "raw.githubusercontent.com/github-linguist/linguist/master/lib/linguist/languages.yml", + // processors/render.gemojis + "api.github.com/emojis", + // processors/render.twemojis + "cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets", + // Testing + "example.com", + "loremflickr.com", + // Browser downloads + "googlechromelabs.github.io/chrome-for-testing", + "edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing" + ], + "run": [ + "deno", + "docker", + "$CHROME_BIN" + ], + "read": [ + "$PWD", + "$TMP", + ".cache" + ], + "write": [ + ".test", + ".cache", + "$TMP", + "$HOME/.config/chromium/SingletonLock" + ], + "env": true + } + } + } + }, + "coverage": { + "description": "đŸ”Ŧ Print coverage report", + "task": "deno coverage .coverage", + "deno": { + "coverage": { + "exclude": "/dom/" + } + } + }, + "cache": { + "description": "đŸ“Ļ Cache dependencies", + "task": [ + "rm -rf deno.lock &&", + "deno cache source/run/**/*.ts" + ], + "deno": { + "cache": { + "modules": { + "reload": true + } + } + } + }, + "lint": { + "description": "🔎 Format and lint code", + "task": [ + "deno fmt &&", + "deno lint" + //"deno check **/*.ts" + ], + "flags": { + "check": { + "description": "Check only" + } + } + }, + "docker": { + "description": "🐋 Build docker image", + "task": "docker build --compress --tag $ --file $ .", + "flags": { + "tag": { + "default": "metrics:dev", + "description": "Docker image tag" + }, + "dockerfile": { + "default": "Dockerfile", + "description": "Dockerfile path" + } + } + }, + "docker:run": { + "description": "🐋 Run docker image interactively", + "task": [ + "docker run --rm --interactive --tty --entrypoint='' --volume //var/run/docker.sock:/var/run/docker.sock --volume $PWD/source:/metrics/source --volume $PWD/deno.jsonc:/metrics/deno.jsonc --user $ $ $" + ], + "flags": { + "tag": { + "default": "metrics:dev", + "description": "Docker image tag" + }, + "user": { + "default": "metrics", + "description": "User to run command as" + }, + "command": { + "default": "sh -c 'sudo chgrp docker /var/run/docker.sock && sudo rm /etc/sudoers.d/metrics && sh'", + "description": "Command to execute" + } + } + }, + "ci:lint": { + "description": "🤖 Lint code (CI)", + "task": "deno task make lint --check" + }, + "ci:test": { + "description": "🤖 Build container and run tests and coverage inside the image (CI)", + "task": [ + "deno task make docker &&", + "docker run --rm --entrypoint='' --volume //var/run/docker.sock:/var/run/docker.sock metrics:dev sh -c 'sudo chgrp docker /var/run/docker.sock && sudo rm /etc/sudoers.d/metrics && deno task make test && deno task make coverage'" + ] + }, + "deploy:deno": { + "description": "đŸĻ• Deploy metrics on https://deno.com/deploy", + "task": [ + "export CACHE_DIRECTORY=$(deno task make get:cache) &&", + "export DENO_DEPLOY_TOKEN=$(deno eval --env \"console.log(Deno.env.get('DENO_DEPLOY_TOKEN'))\") &&", + "deno run source/run/serve/imports.ts &&", + "deployctl deploy --project=$ --include=source,deno.jsonc --prod source/run/mod.ts" + ], + "flags": { + "project": { + "default": "metrics", + "description": "Deno Deploy project name" + } + }, + "deno": { + "eval": { + "env": true + }, + "run": { + "permissions": { + "read": [ + "$PWD", + "$CACHE_DIRECTORY" + ], + "write": [ + "source/run/serve", + "source/run/serve/imported.ts" + ], + "env": true + } + } + } + }, + "get:browser": { + "description": "🏗ī¸ Get browser path (and install it if necessary)", + "task": [ + "deno eval \"$ASTRAL_DOWNLOAD\" &&", + "deno eval \"$ASTRAL_BINARY\"" + ], + "env": { + "ASTRAL_DOWNLOAD": "await import('@engine/utils/browser.ts').then(({Browser}) => Browser.getBinary('chrome'))", + "ASTRAL_BINARY": "await import('@engine/utils/browser.ts').then(async ({Browser}) => console.log(Deno.env.get('CHROME_BIN') || await Browser.getBinary('chrome')))" + }, + "deno": { + "eval": { + "quiet": true, + "permissions": { + "net": [ + "googlechromelabs.github.io/chrome-for-testing", + "edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing" + ], + "write": [ + ".cache" + ], + "env": [ + "CHROME_BIN" + ] + } + } + } + }, + "get:cache": { + "description": "🏗ī¸ Get cache path", + "task": [ + "deno eval \"$GET_CACHE_DIRECTORY\"" + ], + "env": { + "GET_CACHE_DIRECTORY": "await import('x/cache_dir@0.2.0/mod.ts').then(({default:mod}) => console.log(mod()))" + }, + "deno": { + "eval": { + "quiet": true, + "permissions": { + "env": true + } + } + } + } + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 00000000000..d1c40563cc6 --- /dev/null +++ b/deno.lock @@ -0,0 +1,760 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "npm:@types/node": "npm:@types/node@18.16.19" + }, + "npm": { + "@types/node@18.16.19": { + "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", + "dependencies": {} + } + } + }, + "redirects": { + "https://esm.sh/v133/@types/alpinejs@~3.13/index.d.ts": "https://esm.sh/v133/@types/alpinejs@3.13.5/index.d.ts", + "https://esm.sh/v133/@types/chai-as-promised@~7.1/index.d.ts": "https://esm.sh/v133/@types/chai-as-promised@7.1.8/index.d.ts", + "https://esm.sh/v133/@types/chai-subset@^1/index~.d.ts": "https://esm.sh/v133/@types/chai-subset@1.3.5/index~.d.ts", + "https://esm.sh/v133/@types/chai@~4.3/index.d.ts": "https://esm.sh/v133/@types/chai@4.3.11/index.d.ts", + "https://esm.sh/v133/@types/csso@~5.0/index.d.ts": "https://esm.sh/v133/@types/csso@5.0.3/index.d.ts", + "https://esm.sh/v133/@types/d3@^7/index.d.ts": "https://esm.sh/v133/@types/d3@7.4.3/index.d.ts", + "https://esm.sh/v133/@types/ejs@~3.1/index.d.ts": "https://esm.sh/v133/@types/ejs@3.1.5/index.d.ts", + "https://esm.sh/v133/@types/html-escaper@~3.0/index.d.ts": "https://esm.sh/v133/@types/html-escaper@3.0.2/index.d.ts", + "https://esm.sh/v133/@types/pluralize@latest/index.d.ts": "https://esm.sh/v133/@types/pluralize@0.0.33/index.d.ts", + "https://esm.sh/v133/@types/primer__octicons@^19/index.d.ts": "https://esm.sh/v133/@types/primer__octicons@19.6.3/index.d.ts", + "https://esm.sh/v133/@types/sanitize-html@^2/index.d.ts": "https://esm.sh/v133/@types/sanitize-html@2.9.5/index.d.ts" + }, + "remote": { + "https://api.github.com/emojis": "9d2b0d98d88cf1e20ad659bdc6ce67219eac949c598b7d6b58c6284092a385d7", + "https://deno.land/std@0.140.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", + "https://deno.land/std@0.140.0/_util/os.ts": "3b4c6e27febd119d36a416d7a97bd3b0251b77c88942c8f16ee5953ea13e2e49", + "https://deno.land/std@0.140.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d", + "https://deno.land/std@0.140.0/bytes/equals.ts": "fc16dff2090cced02497f16483de123dfa91e591029f985029193dfaa9d894c9", + "https://deno.land/std@0.140.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf", + "https://deno.land/std@0.140.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37", + "https://deno.land/std@0.140.0/fs/_util.ts": "0fb24eb4bfebc2c194fb1afdb42b9c3dda12e368f43e8f2321f84fc77d42cb0f", + "https://deno.land/std@0.140.0/fs/ensure_dir.ts": "9dc109c27df4098b9fc12d949612ae5c9c7169507660dcf9ad90631833209d9d", + "https://deno.land/std@0.140.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b", + "https://deno.land/std@0.140.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", + "https://deno.land/std@0.140.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", + "https://deno.land/std@0.140.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b", + "https://deno.land/std@0.140.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", + "https://deno.land/std@0.140.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", + "https://deno.land/std@0.140.0/path/mod.ts": "d3e68d0abb393fb0bf94a6d07c46ec31dc755b544b13144dee931d8d5f06a52d", + "https://deno.land/std@0.140.0/path/posix.ts": "293cdaec3ecccec0a9cc2b534302dfe308adb6f10861fa183275d6695faace44", + "https://deno.land/std@0.140.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", + "https://deno.land/std@0.140.0/path/win32.ts": "31811536855e19ba37a999cd8d1b62078235548d67902ece4aa6b814596dd757", + "https://deno.land/std@0.140.0/streams/conversion.ts": "712585bfa0172a97fb68dd46e784ae8ad59d11b88079d6a4ab098ff42e697d21", + "https://deno.land/std@0.162.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", + "https://deno.land/std@0.162.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4", + "https://deno.land/std@0.162.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a", + "https://deno.land/std@0.162.0/bytes/mod.ts": "b2e342fd3669176a27a4e15061e9d588b89c1aaf5008ab71766e23669565d179", + "https://deno.land/std@0.162.0/fmt/colors.ts": "9e36a716611dcd2e4865adea9c4bec916b5c60caad4cdcdc630d4974e6bb8bd4", + "https://deno.land/std@0.162.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289", + "https://deno.land/std@0.162.0/streams/conversion.ts": "555c6c249f3acf85655f2d0af52d1cb3168e40b1c1fa26beefea501b333abe28", + "https://deno.land/std@0.186.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", + "https://deno.land/std@0.186.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", + "https://deno.land/std@0.186.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", + "https://deno.land/std@0.186.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", + "https://deno.land/std@0.186.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", + "https://deno.land/std@0.186.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", + "https://deno.land/std@0.186.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", + "https://deno.land/std@0.186.0/path/mod.ts": "ee161baec5ded6510ee1d1fb6a75a0f5e4b41f3f3301c92c716ecbdf7dae910d", + "https://deno.land/std@0.186.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", + "https://deno.land/std@0.186.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", + "https://deno.land/std@0.186.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", + "https://deno.land/std@0.196.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", + "https://deno.land/std@0.196.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.196.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.196.0/console/_data.json": "cf2cc9d039a192b3adbfe64627167c7e6212704c888c25c769fc8f1709e1e1b8", + "https://deno.land/std@0.196.0/console/_rle.ts": "56668d5c44f964f1b4ff93f21c9896df42d6ee4394e814db52d6d13f5bb247c7", + "https://deno.land/std@0.196.0/console/unicode_width.ts": "10661c0f2eeab802d16b8b85ed8825bbc573991bbfb6affed32dc1ff994f54f9", + "https://deno.land/std@0.196.0/encoding/base64.ts": "144ae6234c1fbe5b68666c711dc15b1e9ee2aef6d42b3b4345bf9a6c91d70d0d", + "https://deno.land/std@0.196.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc", + "https://deno.land/std@0.196.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", + "https://deno.land/std@0.196.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", + "https://deno.land/std@0.196.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", + "https://deno.land/std@0.196.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", + "https://deno.land/std@0.196.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", + "https://deno.land/std@0.196.0/path/mod.ts": "f065032a7189404fdac3ad1a1551a9ac84751d2f25c431e101787846c86c79ef", + "https://deno.land/std@0.196.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", + "https://deno.land/std@0.196.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", + "https://deno.land/std@0.196.0/path/win32.ts": "4fca292f8d116fd6d62f243b8a61bd3d6835a9f0ede762ba5c01afe7c3c0aa12", + "https://deno.land/std@0.205.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.205.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.205.0/async/_util.ts": "7fef43ce1949b36ebd092a735c4659f3e63411eb4a3ed0c381bdc6e8f261d835", + "https://deno.land/std@0.205.0/async/deadline.ts": "58f72a3cc0fcb731b2cc055ba046f4b5be3349ff6bf98f2e793c3b969354aab2", + "https://deno.land/std@0.205.0/async/debounce.ts": "adab11d04ca38d699444ac8a9d9856b4155e8dda2afd07ce78276c01ea5a4332", + "https://deno.land/std@0.205.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8", + "https://deno.land/std@0.205.0/async/delay.ts": "a6142eb44cdd856b645086af2b811b1fcce08ec06bb7d50969e6a872ee9b8659", + "https://deno.land/std@0.205.0/async/retry.ts": "b80e37cf1701fe1edf1bec25440e0a870a320193c5dd1990ae0cadd0fc3ab886", + "https://deno.land/std@0.205.0/collections/_utils.ts": "5114abc026ddef71207a79609b984614e66a63a4bda17d819d56b0e72c51527e", + "https://deno.land/std@0.205.0/collections/deep_merge.ts": "9db788ba56cb05b65c77166b789e58e125dff159b7f41bf4d19dc1cba19ecb8b", + "https://deno.land/std@0.205.0/collections/filter_keys.ts": "a29cfe8730ddb54e9e071ea45e8a82e166c7629d18675652def70c1bf80e2ef6", + "https://deno.land/std@0.205.0/encoding/_util.ts": "f368920189c4fe6592ab2e93bd7ded8f3065b84f95cd3e036a4a10a75649dcba", + "https://deno.land/std@0.205.0/encoding/base64.ts": "cc03110d6518170aeaa68ec97f89c6d6e2276294b30807e7332591d7ce2e4b72", + "https://deno.land/std@0.205.0/flags/mod.ts": "0948466fc437f017f00c0b972a422b3dc3317a790bcf326429d23182977eaf9f", + "https://deno.land/std@0.205.0/fmt/bytes.ts": "f29cf69e0791d375f9f5d94ae1f0641e5a03b975f32ddf86d70f70fdf37e7b6a", + "https://deno.land/std@0.205.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", + "https://deno.land/std@0.205.0/front_matter/_formats.ts": "08d92be6e8c616410bf62bf650a731a47d41ffd046498e552f67ef322af6232f", + "https://deno.land/std@0.205.0/front_matter/create_extractor.ts": "7ad9b549d7b85ddb4597e69969f5af08ff5b3b5c76ad76f6b9c76424a6afb1b2", + "https://deno.land/std@0.205.0/front_matter/test.ts": "0085904df50401cd3582dc18bb4863494036619005a81f49b753ad73927ab9fa", + "https://deno.land/std@0.205.0/front_matter/yaml.ts": "4f8f34138bb4b5dbfdce50fc9640038ca7c2a8daef1fb5a2c69c9cccac2ad401", + "https://deno.land/std@0.205.0/fs/_util.ts": "fbf57dcdc9f7bc8128d60301eece608246971a7836a3bb1e78da75314f08b978", + "https://deno.land/std@0.205.0/fs/ensure_dir.ts": "dc64c4c75c64721d4e3fb681f1382f803ff3d2868f08563ff923fdd20d071c40", + "https://deno.land/std@0.205.0/fs/exists.ts": "cb59a853d84871d87acab0e7936a4dac11282957f8e195102c5a7acb42546bb8", + "https://deno.land/std@0.205.0/fs/expand_glob.ts": "4f98c508fc9e40d6311d2f7fd88aaad05235cc506388c22dda315e095305811d", + "https://deno.land/std@0.205.0/fs/walk.ts": "c1e6b43f72a46e89b630140308bd51a4795d416a416b4cfb7cd4bd1e25946723", + "https://deno.land/std@0.205.0/http/cookie.ts": "c6079019fc15c781c302574f40fa2ac71c26b251e8f74eb236ea43e0424edcd7", + "https://deno.land/std@0.205.0/http/etag.ts": "259abf65316c728660e34a100dcb07a1303c37ab6ca3d4ee068503c18b4358b0", + "https://deno.land/std@0.205.0/http/file_server.ts": "7632f763996c74cc7ea8f38bdbf76fb378fc848edc06576e1a13e7340e927b26", + "https://deno.land/std@0.205.0/http/status.ts": "1353e82e27996ef123dc625e5fcc9d66b94d92e5175879fa5e9f0dc39330206a", + "https://deno.land/std@0.205.0/json/common.ts": "ecd5e87d45b5f0df33238ed8b1746e1444da7f5c86ae53d0f0b04280f41a25bb", + "https://deno.land/std@0.205.0/jsonc/parse.ts": "c1096e2b7ffb4996d7ed841dfdb29a4fccc78edcc55299beaa20d6fe5facf7b6", + "https://deno.land/std@0.205.0/media_types/_db.ts": "7606d83e31f23ce1a7968cbaee852810c2cf477903a095696cdc62eaab7ce570", + "https://deno.land/std@0.205.0/media_types/_util.ts": "0879b04cc810ff18d3dcd97d361e03c9dfb29f67d7fc4a9c6c9d387282ef5fe8", + "https://deno.land/std@0.205.0/media_types/content_type.ts": "a7a3cb6a2b6e81101637afcaa9884d655b4568ded84fa7e6169bb690a87ee2aa", + "https://deno.land/std@0.205.0/media_types/extension.ts": "a7cd28c9417143387cdfed27d4e8607ebcf5b1ec27eb8473d5b000144689fe65", + "https://deno.land/std@0.205.0/media_types/extensions_by_type.ts": "43806d6a52a0d6d965ada9d20e60a982feb40bc7a82268178d94edb764694fed", + "https://deno.land/std@0.205.0/media_types/format_media_type.ts": "f5e1073c05526a6f5a516ac5c5587a1abd043bf1039c71cde1166aa4328c8baf", + "https://deno.land/std@0.205.0/media_types/get_charset.ts": "18b88274796fda5d353806bf409eb1d2ddb3f004eb4bd311662c4cdd8ac173db", + "https://deno.land/std@0.205.0/media_types/mod.ts": "d3f0b99f85053bc0b98ecc24eaa3546dfa09b856dc0bbaf60d8956d2cdd710c8", + "https://deno.land/std@0.205.0/media_types/parse_media_type.ts": "31ccf2388ffab31b49500bb89fa0f5de189c8897e2ee6c9954f207637d488211", + "https://deno.land/std@0.205.0/media_types/type_by_extension.ts": "8c210d4e28ea426414dd8c61146eefbcc7e091a89ccde54bbbe883a154856afd", + "https://deno.land/std@0.205.0/media_types/vendor/mime-db.v1.52.0.ts": "6925bbcae81ca37241e3f55908d0505724358cda3384eaea707773b2c7e99586", + "https://deno.land/std@0.205.0/path/_common/assert_path.ts": "061e4d093d4ba5aebceb2c4da3318bfe3289e868570e9d3a8e327d91c2958946", + "https://deno.land/std@0.205.0/path/_common/basename.ts": "0d978ff818f339cd3b1d09dc914881f4d15617432ae519c1b8fdc09ff8d3789a", + "https://deno.land/std@0.205.0/path/_common/common.ts": "9e4233b2eeb50f8b2ae10ecc2108f58583aea6fd3e8907827020282dc2b76143", + "https://deno.land/std@0.205.0/path/_common/constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", + "https://deno.land/std@0.205.0/path/_common/dirname.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397", + "https://deno.land/std@0.205.0/path/_common/format.ts": "11aa62e316dfbf22c126917f5e03ea5fe2ee707386555a8f513d27ad5756cf96", + "https://deno.land/std@0.205.0/path/_common/from_file_url.ts": "ef1bf3197d2efbf0297a2bdbf3a61d804b18f2bcce45548ae112313ec5be3c22", + "https://deno.land/std@0.205.0/path/_common/glob_to_reg_exp.ts": "5c3c2b79fc2294ec803d102bd9855c451c150021f452046312819fbb6d4dc156", + "https://deno.land/std@0.205.0/path/_common/is_glob.ts": "567dce5c6656bdedfc6b3ee6c0833e1e4db2b8dff6e62148e94a917f289c06ad", + "https://deno.land/std@0.205.0/path/_common/normalize.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397", + "https://deno.land/std@0.205.0/path/_common/normalize_string.ts": "88c472f28ae49525f9fe82de8c8816d93442d46a30d6bb5063b07ff8a89ff589", + "https://deno.land/std@0.205.0/path/_common/relative.ts": "1af19d787a2a84b8c534cc487424fe101f614982ae4851382c978ab2216186b4", + "https://deno.land/std@0.205.0/path/_common/strip_trailing_separators.ts": "7ffc7c287e97bdeeee31b155828686967f222cd73f9e5780bfe7dfb1b58c6c65", + "https://deno.land/std@0.205.0/path/_common/to_file_url.ts": "a8cdd1633bc9175b7eebd3613266d7c0b6ae0fb0cff24120b6092ac31662f9ae", + "https://deno.land/std@0.205.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", + "https://deno.land/std@0.205.0/path/_os.ts": "30b0c2875f360c9296dbe6b7f2d528f0f9c741cecad2e97f803f5219e91b40a2", + "https://deno.land/std@0.205.0/path/basename.ts": "04bb5ef3e86bba8a35603b8f3b69537112cdd19ce64b77f2522006da2977a5f3", + "https://deno.land/std@0.205.0/path/common.ts": "f4d061c7d0b95a65c2a1a52439edec393e906b40f1caf4604c389fae7caa80f5", + "https://deno.land/std@0.205.0/path/dirname.ts": "88a0a71c21debafc4da7a4cd44fd32e899462df458fbca152390887d41c40361", + "https://deno.land/std@0.205.0/path/extname.ts": "2da4e2490f3b48b7121d19fb4c91681a5e11bd6bd99df4f6f47d7a71bb6ecdf2", + "https://deno.land/std@0.205.0/path/format.ts": "3457530cc85d1b4bab175f9ae73998b34fd456c830d01883169af0681b8894fb", + "https://deno.land/std@0.205.0/path/from_file_url.ts": "e7fa233ea1dff9641e8d566153a24d95010110185a6f418dd2e32320926043f8", + "https://deno.land/std@0.205.0/path/glob.ts": "9c77cf47db1d786e2ebf66670824d03fd84ecc7c807cac24441eb9d5cb6a2986", + "https://deno.land/std@0.205.0/path/is_absolute.ts": "67232b41b860571c5b7537f4954c88d86ae2ba45e883ee37d3dec27b74909d13", + "https://deno.land/std@0.205.0/path/join.ts": "98d3d76c819af4a11a81d5ba2dbb319f1ce9d63fc2b615597d4bcfddd4a89a09", + "https://deno.land/std@0.205.0/path/mod.ts": "2d62a0a8b78a60e8e6f485d881bac6b61d58573b11cf585fb7c8fc50d9b20d80", + "https://deno.land/std@0.205.0/path/normalize.ts": "aa95be9a92c7bd4f9dc0ba51e942a1973e2b93d266cd74f5ca751c136d520b66", + "https://deno.land/std@0.205.0/path/parse.ts": "d87ff0deef3fb495bc0d862278ff96da5a06acf0625ca27769fc52ac0d3d6ece", + "https://deno.land/std@0.205.0/path/posix/_util.ts": "ecf49560fedd7dd376c6156cc5565cad97c1abe9824f4417adebc7acc36c93e5", + "https://deno.land/std@0.205.0/path/posix/basename.ts": "a630aeb8fd8e27356b1823b9dedd505e30085015407caa3396332752f6b8406a", + "https://deno.land/std@0.205.0/path/posix/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b", + "https://deno.land/std@0.205.0/path/posix/dirname.ts": "f48c9c42cc670803b505478b7ef162c7cfa9d8e751b59d278b2ec59470531472", + "https://deno.land/std@0.205.0/path/posix/extname.ts": "ee7f6571a9c0a37f9218fbf510c440d1685a7c13082c348d701396cc795e0be0", + "https://deno.land/std@0.205.0/path/posix/format.ts": "b94876f77e61bfe1f147d5ccb46a920636cd3cef8be43df330f0052b03875968", + "https://deno.land/std@0.205.0/path/posix/from_file_url.ts": "b97287a83e6407ac27bdf3ab621db3fccbf1c27df0a1b1f20e1e1b5acf38a379", + "https://deno.land/std@0.205.0/path/posix/glob.ts": "86c3f06d1c98303613c74650961c3e24bdb871cde2a97c3ae7f0f6d4abbef445", + "https://deno.land/std@0.205.0/path/posix/is_absolute.ts": "159900a3422d11069d48395568217eb7fc105ceda2683d03d9b7c0f0769e01b8", + "https://deno.land/std@0.205.0/path/posix/join.ts": "0c0d84bdc344876930126640011ec1b888e6facf74153ffad9ef26813aa2a076", + "https://deno.land/std@0.205.0/path/posix/mod.ts": "6bfa8a42d85345b12dbe8571028ca2c62d460b6ef968125e498602b43b6cf6b6", + "https://deno.land/std@0.205.0/path/posix/normalize.ts": "11de90a94ab7148cc46e5a288f7d732aade1d616bc8c862f5560fa18ff987b4b", + "https://deno.land/std@0.205.0/path/posix/parse.ts": "199208f373dd93a792e9c585352bfc73a6293411bed6da6d3bc4f4ef90b04c8e", + "https://deno.land/std@0.205.0/path/posix/relative.ts": "e2f230608b0f083e6deaa06e063943e5accb3320c28aef8d87528fbb7fe6504c", + "https://deno.land/std@0.205.0/path/posix/resolve.ts": "51579d83159d5c719518c9ae50812a63959bbcb7561d79acbdb2c3682236e285", + "https://deno.land/std@0.205.0/path/posix/separator.ts": "0b6573b5f3269a3164d8edc9cefc33a02dd51003731c561008c8bb60220ebac1", + "https://deno.land/std@0.205.0/path/posix/to_file_url.ts": "08d43ea839ee75e9b8b1538376cfe95911070a655cd312bc9a00f88ef14967b6", + "https://deno.land/std@0.205.0/path/posix/to_namespaced_path.ts": "c9228a0e74fd37e76622cd7b142b8416663a9b87db643302fa0926b5a5c83bdc", + "https://deno.land/std@0.205.0/path/relative.ts": "23d45ede8b7ac464a8299663a43488aad6b561414e7cbbe4790775590db6349c", + "https://deno.land/std@0.205.0/path/resolve.ts": "5b184efc87155a0af9fa305ff68a109e28de9aee81fc3e77cd01380f19daf867", + "https://deno.land/std@0.205.0/path/separator.ts": "40a3e9a4ad10bef23bc2cd6c610291b6c502a06237c2c4cd034a15ca78dedc1f", + "https://deno.land/std@0.205.0/path/to_file_url.ts": "edaafa089e0bce386e1b2d47afe7c72e379ff93b28a5829a5885e4b6c626d864", + "https://deno.land/std@0.205.0/path/to_namespaced_path.ts": "cf8734848aac3c7527d1689d2adf82132b1618eff3cc523a775068847416b22a", + "https://deno.land/std@0.205.0/path/windows/_util.ts": "f32b9444554c8863b9b4814025c700492a2b57ff2369d015360970a1b1099d54", + "https://deno.land/std@0.205.0/path/windows/basename.ts": "8a9dbf7353d50afbc5b221af36c02a72c2d1b2b5b9f7c65bf6a5a2a0baf88ad3", + "https://deno.land/std@0.205.0/path/windows/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b", + "https://deno.land/std@0.205.0/path/windows/dirname.ts": "5c2aa541384bf0bd9aca821275d2a8690e8238fa846198ef5c7515ce31a01a94", + "https://deno.land/std@0.205.0/path/windows/extname.ts": "07f4fa1b40d06a827446b3e3bcc8d619c5546b079b8ed0c77040bbef716c7614", + "https://deno.land/std@0.205.0/path/windows/format.ts": "343019130d78f172a5c49fdc7e64686a7faf41553268961e7b6c92a6d6548edf", + "https://deno.land/std@0.205.0/path/windows/from_file_url.ts": "d53335c12b0725893d768be3ac6bf0112cc5b639d2deb0171b35988493b46199", + "https://deno.land/std@0.205.0/path/windows/glob.ts": "0286fb89ecd21db5cbf3b6c79e2b87c889b03f1311e66fb769e6b905d4142332", + "https://deno.land/std@0.205.0/path/windows/is_absolute.ts": "245b56b5f355ede8664bd7f080c910a97e2169972d23075554ae14d73722c53c", + "https://deno.land/std@0.205.0/path/windows/join.ts": "e6600bf88edeeef4e2276e155b8de1d5dec0435fd526ba2dc4d37986b2882f16", + "https://deno.land/std@0.205.0/path/windows/mod.ts": "c3d1a36fbf9f6db1320bcb4fbda8de011d25461be3497105e15cbea1e3726198", + "https://deno.land/std@0.205.0/path/windows/normalize.ts": "9deebbf40c81ef540b7b945d4ccd7a6a2c5a5992f791e6d3377043031e164e69", + "https://deno.land/std@0.205.0/path/windows/parse.ts": "120faf778fe1f22056f33ded069b68e12447668fcfa19540c0129561428d3ae5", + "https://deno.land/std@0.205.0/path/windows/relative.ts": "026855cd2c36c8f28f1df3c6fbd8f2449a2aa21f48797a74700c5d872b86d649", + "https://deno.land/std@0.205.0/path/windows/resolve.ts": "5ff441ab18a2346abadf778121128ee71bda4d0898513d4639a6ca04edca366b", + "https://deno.land/std@0.205.0/path/windows/separator.ts": "ae21f27015f10510ed1ac4a0ba9c4c9c967cbdd9d9e776a3e4967553c397bd5d", + "https://deno.land/std@0.205.0/path/windows/to_file_url.ts": "8e9ea9e1ff364aa06fa72999204229952d0a279dbb876b7b838b2b2fea55cce3", + "https://deno.land/std@0.205.0/path/windows/to_namespaced_path.ts": "e0f4d4a5e77f28a5708c1a33ff24360f35637ba6d8f103d19661255ef7bfd50d", + "https://deno.land/std@0.205.0/semver/_shared.ts": "8547ccf91b36c30fb2a8a17d7081df13f4ae694c4aa44c39799eba69ad0dcb23", + "https://deno.land/std@0.205.0/semver/cmp.ts": "12c30b5888afd9e414defef64f881a478ff9ab11bd329ed6c5844b74eea5c971", + "https://deno.land/std@0.205.0/semver/compare.ts": "782e03b5107648bebaaebf0e33a9a7d6a0481eb88d2f7be8e857e4abbfdf42c0", + "https://deno.land/std@0.205.0/semver/constants.ts": "bb0c7652c433c7ec1dad5bf18c7e7e1557efe9ddfd5e70aa6305153e76dc318c", + "https://deno.land/std@0.205.0/semver/eq.ts": "6ddb84ce8c95f18e9b7a46d8a63b1e6ca5f0c0f651f1f46f20db6543b390c3f3", + "https://deno.land/std@0.205.0/semver/gt.ts": "8529cf2ae1bca95c22801cf38f93620dc802c5dcbc02f863437571b970de3705", + "https://deno.land/std@0.205.0/semver/gte.ts": "b54f7855ac37ff076d6df9a294e944356754171f94f5cb974af782480a9f1fd0", + "https://deno.land/std@0.205.0/semver/is_semver.ts": "666f4e1d8e41994150d4326d515046bc5fc72e59cbbd6e756a0b60548dcd00b5", + "https://deno.land/std@0.205.0/semver/lt.ts": "081614b5adbc5bc944649e09af946a90a4b4bdb3d65a67c005183994504f04c2", + "https://deno.land/std@0.205.0/semver/lte.ts": "f8605c17d620bfb3aa57775643e3c560c04f7c20f2e431f64ca5b2ea39e36217", + "https://deno.land/std@0.205.0/semver/neq.ts": "e91b699681c3b406fc3d661d4eac7aa36cd1cc8bf188f8e3c7b53cc340775b87", + "https://deno.land/std@0.205.0/semver/parse.ts": "5d24ec0c5f681db1742c31332f6007395c84696c88ff4b58287485ed3f6d8c84", + "https://deno.land/std@0.205.0/semver/types.ts": "d44f442c2f27dd89bd6695b369e310b80549746f03c38f241fe28a83b33dd429", + "https://deno.land/std@0.205.0/streams/_common.ts": "3b2c1f0287ce2ad51fff4091a7d0f48375c85b0ec341468e76d5ac13bb0014dd", + "https://deno.land/std@0.205.0/streams/byte_slice_stream.ts": "c46d7c74836fc8c1a9acd9fe211cbe1bbaaee1b36087c834fb03af4991135c3a", + "https://deno.land/std@0.205.0/streams/text_delimiter_stream.ts": "f0dc8ff953a8a77f0d1fa8db1fee62de817f36e20d79b00b1362847e30fbdd90", + "https://deno.land/std@0.205.0/version.ts": "c692162c2e110d6cc88fcb27e1ae47434360d2ec2930950f5c1a6ee0addf8ea7", + "https://deno.land/std@0.205.0/yaml/_dumper/dumper.ts": "717403d0e700de783f2ef5c906b3d7245383e1509fc050e7ff5d4a53a03dbf40", + "https://deno.land/std@0.205.0/yaml/_dumper/dumper_state.ts": "f0d0673ceea288334061ca34b63954c2bb5feb5bf6de5e4cfe9a942cdf6e5efe", + "https://deno.land/std@0.205.0/yaml/_error.ts": "b59e2c76ce5a47b1b9fa0ff9f96c1dd92ea1e1b17ce4347ece5944a95c3c1a84", + "https://deno.land/std@0.205.0/yaml/_loader/loader.ts": "63ec7f0a265dbbabc54b25a4beefff7650e205160a2d75c7d8f8363b5f84851a", + "https://deno.land/std@0.205.0/yaml/_loader/loader_state.ts": "0841870b467169269d7c2dfa75cd288c319bc06f65edd9e42c29e5fced91c7a4", + "https://deno.land/std@0.205.0/yaml/_mark.ts": "dcd8585dee585e024475e9f3fe27d29740670fb64ebb970388094cad0fc11d5d", + "https://deno.land/std@0.205.0/yaml/_state.ts": "ef03d55ec235d48dcfbecc0ab3ade90bfae69a61094846e08003421c2cf5cfc6", + "https://deno.land/std@0.205.0/yaml/_type/binary.ts": "24d49614463a7339a8a16d894919c2ec18a10588ae360ec352093b60e2cc8b0d", + "https://deno.land/std@0.205.0/yaml/_type/bool.ts": "5bfa75da84343d45347b521ba4e5aeace9fe6f53447405290d53315a3fc20e66", + "https://deno.land/std@0.205.0/yaml/_type/float.ts": "056bd3cb9c5586238b20517511014fb24b0e36f98f9f6073e12da308b6b9808a", + "https://deno.land/std@0.205.0/yaml/_type/function.ts": "ff574fe84a750695302864e1c31b93f12d14ada4bde79a5f93197fc33ad17471", + "https://deno.land/std@0.205.0/yaml/_type/int.ts": "563ad074f0fa7aecf6b6c3d84135bcc95a8269dcc15de878de20ce868fd773fa", + "https://deno.land/std@0.205.0/yaml/_type/map.ts": "7b105e4ab03a361c61e7e335a0baf4d40f06460b13920e5af3fb2783a1464000", + "https://deno.land/std@0.205.0/yaml/_type/merge.ts": "8192bf3e4d637f32567917f48bb276043da9cf729cf594e5ec191f7cd229337e", + "https://deno.land/std@0.205.0/yaml/_type/mod.ts": "060e2b3d38725094b77ea3a3f05fc7e671fced8e67ca18e525be98c4aa8f4bbb", + "https://deno.land/std@0.205.0/yaml/_type/nil.ts": "606e8f0c44d73117c81abec822f89ef81e40f712258c74f186baa1af659b8887", + "https://deno.land/std@0.205.0/yaml/_type/omap.ts": "cfe59a294726f5cea705c39a61fd2b08199cf48f4ccd6b040cb550ec0f38d0a1", + "https://deno.land/std@0.205.0/yaml/_type/pairs.ts": "0032fdfe57558d21696a4f8cf5b5cfd1f698743177080affc18629685c905666", + "https://deno.land/std@0.205.0/yaml/_type/regexp.ts": "1ce118de15b2da43b4bd8e4395f42d448b731acf3bdaf7c888f40789f9a95f8b", + "https://deno.land/std@0.205.0/yaml/_type/seq.ts": "95333abeec8a7e4d967b8c8328b269e342a4bbdd2585395549b9c4f58c8533a2", + "https://deno.land/std@0.205.0/yaml/_type/set.ts": "f28ba44e632ef2a6eb580486fd47a460445eeddbdf1dbc739c3e62486f566092", + "https://deno.land/std@0.205.0/yaml/_type/str.ts": "a67a3c6e429d95041399e964015511779b1130ea5889fa257c48457bd3446e31", + "https://deno.land/std@0.205.0/yaml/_type/timestamp.ts": "706ea80a76a73e48efaeb400ace087da1f927647b53ad6f754f4e06d51af087f", + "https://deno.land/std@0.205.0/yaml/_type/undefined.ts": "94a316ca450597ccbc6750cbd79097ad0d5f3a019797eed3c841a040c29540ba", + "https://deno.land/std@0.205.0/yaml/_utils.ts": "26b311f0d42a7ce025060bd6320a68b50e52fd24a839581eb31734cd48e20393", + "https://deno.land/std@0.205.0/yaml/mod.ts": "28ecda6652f3e7a7735ee29c247bfbd32a2e2fc5724068e9fd173ec4e59f66f7", + "https://deno.land/std@0.205.0/yaml/parse.ts": "1fbbda572bf3fff578b6482c0d8b85097a38de3176bf3ab2ca70c25fb0c960ef", + "https://deno.land/std@0.205.0/yaml/schema.ts": "96908b78dc50c340074b93fc1598d5e7e2fe59103f89ff81e5a49b2dedf77a67", + "https://deno.land/std@0.205.0/yaml/schema/core.ts": "fa406f18ceedc87a50e28bb90ec7a4c09eebb337f94ef17468349794fa828639", + "https://deno.land/std@0.205.0/yaml/schema/default.ts": "0047e80ae8a4a93293bc4c557ae8a546aabd46bb7165b9d9b940d57b4d88bde9", + "https://deno.land/std@0.205.0/yaml/schema/extended.ts": "0784416bf062d20a1626b53c03380e265b3e39b9409afb9f4cb7d659fd71e60d", + "https://deno.land/std@0.205.0/yaml/schema/failsafe.ts": "d219ab5febc43f770917d8ec37735a4b1ad671149846cbdcade767832b42b92b", + "https://deno.land/std@0.205.0/yaml/schema/json.ts": "5f41dd7c2f1ad545ef6238633ce9ee3d444dfc5a18101e1768bd5504bf90e5e5", + "https://deno.land/std@0.205.0/yaml/schema/mod.ts": "4472e827bab5025e92bc2eb2eeefa70ecbefc64b2799b765c69af84822efef32", + "https://deno.land/std@0.205.0/yaml/stringify.ts": "fffc09c65c68d3d63f8159e8cbaa3f489bc20a8e55b4fbb61a8c2e9f914d1d02", + "https://deno.land/std@0.205.0/yaml/type.ts": "65553da3da3c029b6589c6e4903f0afbea6768be8fca61580711457151f2b30f", + "https://deno.land/x/astral@0.3.2/bindings/celestial.ts": "637f41276b9b344f57dc7443b287b4277ef4bd71fc9db17b8a0636fae448e996", + "https://deno.land/x/astral@0.3.2/mod.ts": "c07ced6f4434b839ff4ab90e7eec00da31e31c593ae8938f37a9e87e217f16d4", + "https://deno.land/x/astral@0.3.2/src/browser.ts": "daf3bb073910264a207503282a26a4151f7ea7eb05abbf1c7ca46e9f419a2688", + "https://deno.land/x/astral@0.3.2/src/cache.ts": "306de10c8173185462979465493e667e04727d42ccc34edd524c34aca35a5e16", + "https://deno.land/x/astral@0.3.2/src/dialog.ts": "2b198b672d88c14ca33ee70d8f959a91ccc70375b71c4bc16b1a24bbdc6396d7", + "https://deno.land/x/astral@0.3.2/src/element_handle.ts": "5360632894424ffb328748f1ee430efbcfeb38b2cb68e5db1af346acd551e1c3", + "https://deno.land/x/astral@0.3.2/src/file_chooser.ts": "a17718a6ece537e4b5e7f13cf9193cd7543be4525c158db102f2cac0e2f5bfb6", + "https://deno.land/x/astral@0.3.2/src/keyboard.ts": "84b65bd9ae7594c61a7f7952857d70caa0f3d60250da989fce31c54390cb8f53", + "https://deno.land/x/astral@0.3.2/src/mouse.ts": "9d3eb409afea003a54ceb42f29b61ec56854c7528c5761a494ea8b095ae8af8d", + "https://deno.land/x/astral@0.3.2/src/page.ts": "6d3fae3f92148b15dea052d8cabcf5ca3a9e82d6b4e0e4e695a7d72de4125e4f", + "https://deno.land/x/astral@0.3.2/src/touchscreen.ts": "ceca95f3a790ea71505d79bae51c4300d4b6424af2b41f4012270f955a892500", + "https://deno.land/x/astral@0.3.2/src/util.ts": "e32359a77fc3da18d014662af1c275c10fdadbac36b35c6dcb28f83c33ddbfdc", + "https://deno.land/x/cache_dir@0.2.0/mod.ts": "89ac1bc4d8f1c52fde192e0470d0597fa156ecf2b53d7fe162a11fd365c79920", + "https://deno.land/x/cliffy@v1.0.0-rc.3/_utils/distance.ts": "02af166952c7c358ac83beae397aa2fbca4ad630aecfcd38d92edb1ea429f004", + "https://deno.land/x/cliffy@v1.0.0-rc.3/ansi/ansi_escapes.ts": "193b3c3a4e520274bd8322ca4cab1c3ce38070bed1898cb2ade12a585dddd7c9", + "https://deno.land/x/cliffy@v1.0.0-rc.3/ansi/chain.ts": "eca61b1b64cad7b9799490c12c7aa5538d0f63ac65a73ddb6acac8b35f0a5323", + "https://deno.land/x/cliffy@v1.0.0-rc.3/ansi/cursor_position.ts": "caa008d29f7a904908bda514f9839bfbb7a93f2d5f5580501675b646d26a87ff", + "https://deno.land/x/cliffy@v1.0.0-rc.3/ansi/deps.ts": "f48ae5d066684793f4a203524db2a9fd61f514527934b458006f3e57363c0215", + "https://deno.land/x/cliffy@v1.0.0-rc.3/ansi/tty.ts": "155aacdcb7dc00f3f95352616a2415c622ffb88db51c5934e5d2e8341eab010b", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/_argument_types.ts": "ab269dacea2030f865a07c2a1e953ec437a64419a05bad1f1ddaab3f99752ead", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/_errors.ts": "12d513ff401020287a344e0830e1297ce1c80c077ecb91e0ac5db44d04a6019c", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/_spread.ts": "0cc6eb70a6df97b5d7d26008822d39f3e8a1232ee0a27f395aa19e68de738245", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/_type_utils.ts": "820004a59bc858e355b11f80e5b3ff1be2c87e66f31f53f253610170795602f0", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/_utils.ts": "3c88ff4f36eba298beb07de08068fdce5e5cb7b9d82c8a319f09596d8279be64", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/command.ts": "ae690745759524082776b7f271f66d5b93933170b1b132f888bd4ac12e9fdd7d", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/completions/_bash_completions_generator.ts": "0c6cb1df4d378d22f001155781d97a9c3519fd10c48187a198fef2cc63b0f84a", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/completions/_fish_completions_generator.ts": "8ba4455f7f76a756e05c3db4ce35332b2951af65a2891f2750b530e06880f495", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/completions/_zsh_completions_generator.ts": "c74525feaf570fe8c14433c30d192622c25603f1fc64694ef69f2a218b41f230", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/completions/bash.ts": "53fe78994eb2359110dc4fa79235bdd86800a38c1d6b1c4fe673c81756f3a0e2", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/completions/complete.ts": "58df61caa5e6220ff2768636a69337923ad9d4b8c1932aeb27165081c4d07d8b", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/completions/completions_command.ts": "506f97f1c6b0b1c3e9956e5069070028b818942310600d4157f64c9b644d3c49", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/completions/fish.ts": "6f0b44b4067740b2931c9ec8863b6619b1d3410fea0c5a3988525a4c53059197", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/completions/mod.ts": "8dda715ca25f3f66d5ec232b76d7c9a96dd4c64b5029feff91738cc0c9586fb1", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/completions/zsh.ts": "f1263c3946975e090d4aadc8681db811d86b52a8ae680f246e03248025885c21", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/deprecated.ts": "bbe6670f1d645b773d04b725b8b8e7814c862c9f1afba460c4d599ffe9d4983c", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/deps.ts": "7473ebd5625bf901becd7ff80afdde3b8a50ae5d1bbfa2f43805cfacf4559d5a", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/help/_help_generator.ts": "532dd4a928baab8b45ce46bb6d20e2ebacfdf3da141ce9d12da796652b1de478", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/help/help_command.ts": "fbbf0c0827dd21d3cec7bcc68c00c20b55f53e2b621032891b9d23ac4191231c", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/help/mod.ts": "8369b292761dcc9ddaf41f2d34bfb06fb6800b69efe80da4fc9752c3b890275b", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/mod.ts": "4b708df1b97152522bee0e3828f06abbbc1d2250168910e5cf454950d7b7404b", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/type.ts": "f588f5d9635b79100044e62aced4b00e510e75b83801f9b089c40c2d98674de2", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types.ts": "bc9ff7459b9cc1079eeb95ff101690a51b4b4afa4af5623340076ee361d08dbb", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types/action_list.ts": "33c98d449617c7a563a535c9ceb3741bde9f6363353fd492f90a74570c611c27", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types/boolean.ts": "3879ec16092b4b5b1a0acb8675f8c9250c0b8a972e1e4c7adfba8335bd2263ed", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types/child_command.ts": "f1fca390c7fbfa7a713ca15ef55c2c7656bcbb394d50e8ef54085bdf6dc22559", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types/command.ts": "325d0382e383b725fd8d0ef34ebaeae082c5b76a1f6f2e843fee5dbb1a4fe3ac", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types/enum.ts": "8a7cd2898e03089234083bb78c8b1d9b7172254c53c32d4710321638165a48ec", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types/file.ts": "8618f16ac9015c8589cbd946b3de1988cc4899b90ea251f3325c93c46745140e", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types/integer.ts": "29864725fd48738579d18123d7ee78fed37515e6dc62146c7544c98a82f1778d", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types/number.ts": "aeba96e6f470309317a16b308c82e0e4138a830ec79c9877e4622c682012bc1f", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/types/string.ts": "e4dadb08a11795474871c7967beab954593813bb53d9f69ea5f9b734e43dc0e0", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/upgrade/_check_version.ts": "6cfa7dc26bc0dc46381500e8d4b130fb224f4c5456152dada15bd3793edca89b", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/upgrade/mod.ts": "4eff69c489467be17dea27fb95a795396111ee385d170ac0cbcc82f0ea38156c", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/upgrade/provider.ts": "c23253334097dc4b8a147ccdeb3aa44f5a95aa953a6386cb5396f830d95d77a5", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/upgrade/provider/deno_land.ts": "24f8d82e38c51e09be989f30f8ad21f9dd41ac1bb1973b443a13883e8ba06d6d", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/upgrade/provider/github.ts": "99e1b133dd446c6aa79f69e69c46eb8bc1c968dd331c2a7d4064514a317c7b59", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/upgrade/provider/nest_land.ts": "0e07936cea04fa41ac9297f32d87f39152ea873970c54cb5b4934b12fee1885e", + "https://deno.land/x/cliffy@v1.0.0-rc.3/command/upgrade/upgrade_command.ts": "3640a287d914190241ea1e636774b1b4b0e1828fa75119971dd5304784061e05", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/_errors.ts": "f1fbb6bfa009e7950508c9d491cfb4a5551027d9f453389606adb3f2327d048f", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/_utils.ts": "340d3ecab43cde9489187e1f176504d2c58485df6652d1cdd907c0e9c3ce4cc2", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/_validate_flags.ts": "e60b9038c0136ab7e6bd1baf0e993a07bf23f18afbfb6e12c59adf665a622957", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/deprecated.ts": "a72a35de3cc7314e5ebea605ca23d08385b218ef171c32a3f135fb4318b08126", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/flags.ts": "3e62c4a9756b5705aada29e7e94847001356b3a83cd18ad56f4207387a71cf51", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/types.ts": "9e2f75edff2217d972fc711a21676a59dfd88378da2f1ace440ea84c07db1dcc", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/types/boolean.ts": "4c026dd66ec9c5436860dc6d0241427bdb8d8e07337ad71b33c08193428a2236", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/types/integer.ts": "b60d4d590f309ddddf066782d43e4dc3799f0e7d08e5ede7dc62a5ee94b9a6d9", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/types/number.ts": "610936e2d29de7c8c304b65489a75ebae17b005c6122c24e791fbed12444d51e", + "https://deno.land/x/cliffy@v1.0.0-rc.3/flags/types/string.ts": "e89b6a5ce322f65a894edecdc48b44956ec246a1d881f03e97bbda90dd8638c5", + "https://deno.land/x/cliffy@v1.0.0-rc.3/keycode/_key_codes.ts": "917f0a2da0dbace08cf29bcfdaaa2257da9fe7e705fff8867d86ed69dfb08cfe", + "https://deno.land/x/cliffy@v1.0.0-rc.3/keycode/key_code.ts": "730fa675ca12fc2a99ba718aa8dbebb1f2c89afd47484e30ef3cb705ddfca367", + "https://deno.land/x/cliffy@v1.0.0-rc.3/keycode/mod.ts": "981b828bddada634e62a2a067b9d1592986180c4e920eb55e0a43cc085eb98ab", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/_figures.ts": "e22413ddd51bb271b6b861a058742e83aaa3f62c14e8162cb73ae6f047062f51", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/_generic_input.ts": "870dad97077582439cee26cb19aec123b4850376331338abdc64a91224733cdc", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/_generic_list.ts": "8b0bea4521b1e2f62c564e0d3764a63264043694f4228bb0bc0b63ce129ef33b", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/_generic_prompt.ts": "4c9d9cdeda749620a3f5332524df13d083e2d59b1ed90a003f43cd0991a75a10", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/_generic_suggestions.ts": "5e6ee1190b4dd5af261ae2ff0196dec7f1988ea9c41c6288cfaece293703002c", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/_utils.ts": "498ae639d7666599d612b615ee85de9103b3c3a913d5196f6b265072674258c7", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/checkbox.ts": "9cfd71f1e278d0ef76054be103d956b66995593902c149380d01b1a1647025f3", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/confirm.ts": "ff892331f6de281079421fe2f57f1d56acb38f28bc48678f87a3fc11ef4a5f7c", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/deps.ts": "2560142f070bb2668e2e8a74683c799461648b9aad01bbf36b3cad3851d712e6", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/input.ts": "81821244f895cc4db32c2511c17e21fb48fd7606e300605aeb2a231ab1680544", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/list.ts": "e5d3e1a6d931b9736d03eec2425fb7b4d2b8d1461a84e210b4787edda414dca4", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/mod.ts": "f8789193742daf3aba93b543a2ea099383284d60fcccc03567102e28c0d61927", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/number.ts": "5421bf1b6411a6f02c44da4e867f19e02315450769e0feacab3c1c88cc1b06d6", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/prompt.ts": "f10e1c8a0c2ca093a485f7f1156342210b27a8cffc96fe0b4cff60007cabab30", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/secret.ts": "cece271c7ce01e12b249c31c2f9cea9e53b6e6be7621a478dac902bd8f288b61", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/select.ts": "c10902aeaca02a55d9b846934958dd166ee39c741faebdaa9800689e402186cf", + "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/toggle.ts": "028f80de31750e7b5479727a64b4878f090ecd783fe3bb0d286e2e1c29f0eee3", + "https://deno.land/x/cliffy@v1.0.0-rc.3/table/_layout.ts": "e4a518da28333de95ad791208b9930025987c8b93d5f8b7f30b377b3e26b24e1", + "https://deno.land/x/cliffy@v1.0.0-rc.3/table/_utils.ts": "fd48d1a524a42e72aa3ad2eec858a92f5a00728d306c7e8436fba6c34314fee6", + "https://deno.land/x/cliffy@v1.0.0-rc.3/table/border.ts": "5c6e9ef5078c6930169aacb668b274bdbb498461c724a7693ac9270fe9d3f5d5", + "https://deno.land/x/cliffy@v1.0.0-rc.3/table/cell.ts": "1ffabd43b6b7fddfac9625cb0d015532e144702a9bfed03b358b79375115d06b", + "https://deno.land/x/cliffy@v1.0.0-rc.3/table/column.ts": "cf14009f2cb14bad156f879946186c1893acdc6a2fee6845db152edddb6a2714", + "https://deno.land/x/cliffy@v1.0.0-rc.3/table/consume_words.ts": "456e75755fdf6966abdefb8b783df2855e2a8bad6ddbdf21bd748547c5fc1d4b", + "https://deno.land/x/cliffy@v1.0.0-rc.3/table/deps.ts": "1226c4d39d53edc81d7c3e661fb8a79f2e704937c276c60355cd4947a0fe9153", + "https://deno.land/x/cliffy@v1.0.0-rc.3/table/row.ts": "79eb1468aafdd951e5963898cdafe0752d4ab4c519d5f847f3d8ecb8fe857d4f", + "https://deno.land/x/cliffy@v1.0.0-rc.3/table/table.ts": "298671e72e61f1ab18b42ae36643181993f79e29b39dc411fdc6ffd53aa04684", + "https://deno.land/x/deno_cache@0.5.2/auth_tokens.ts": "5d1d56474c54a9d152e44d43ea17c2e6a398dd1e9682c69811a313567c01ee1e", + "https://deno.land/x/deno_cache@0.5.2/cache.ts": "92ce8511e1e5c00fdf53a41619aa77d632ea8e0fc711324322e4d5ebf8133911", + "https://deno.land/x/deno_cache@0.5.2/deno_dir.ts": "1ea355b8ba11c630d076b222b197cfc937dd81e5a4a260938997da99e8ff93a0", + "https://deno.land/x/deno_cache@0.5.2/deps.ts": "26a75905652510b76e54b6d5ef3cf824d1062031e00782efcd768978419224e7", + "https://deno.land/x/deno_cache@0.5.2/dirs.ts": "009c6f54e0b610914d6ce9f72f6f6ccfffd2d47a79a19061e0a9eb4253836069", + "https://deno.land/x/deno_cache@0.5.2/disk_cache.ts": "66a1e604a8d564b6dd0500326cac33d08b561d331036bf7272def80f2f7952aa", + "https://deno.land/x/deno_cache@0.5.2/file_fetcher.ts": "89616c50b6df73fb04e73d0b7cd99e5f2ed7967386913d65b9e8baa4238501f7", + "https://deno.land/x/deno_cache@0.5.2/http_cache.ts": "407135eaf2802809ed373c230d57da7ef8dff923c4abf205410b9b99886491fd", + "https://deno.land/x/deno_cache@0.5.2/lib/deno_cache_dir.generated.js": "18b6526d0c50791a73dd0eb894e99de1ac05ee79dcbd53298ff5b5b6b0757fe6", + "https://deno.land/x/deno_cache@0.5.2/lib/snippets/deno_cache_dir-77bed54ace8005e0/fs.js": "cbe3a976ed63c72c7cb34ef845c27013033a3b11f9d8d3e2c4aa5dda2c0c7af6", + "https://deno.land/x/deno_cache@0.5.2/mod.ts": "0b4d071ad095128bdc2b1bc6e5d2095222dcbae08287261690ee9757e6300db6", + "https://deno.land/x/deno_cache@0.5.2/util.ts": "f3f5a0cfc60051f09162942fb0ee87a0e27b11a12aec4c22076e3006be4cc1e2", + "https://deno.land/x/deno_dom@v0.1.38/build/deno-wasm/deno-wasm.js": "98b1ad24a1c13284557917659402202e5c5258ab1431b3f3a82434ad36ffa05a", + "https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts": "bfd999a493a6974e9fca4d331bee03bfb68cfc600c662cd0b48b21d67a2a8ba0", + "https://deno.land/x/deno_dom@v0.1.38/src/api.ts": "0ff5790f0a3eeecb4e00b7d8fbfa319b165962cf6d0182a65ba90f158d74f7d7", + "https://deno.land/x/deno_dom@v0.1.38/src/constructor-lock.ts": "59714df7e0571ec7bd338903b1f396202771a6d4d7f55a452936bd0de9deb186", + "https://deno.land/x/deno_dom@v0.1.38/src/deserialize.ts": "f4d34514ca00473ca428b69ad437ba345925744b5d791cb9552e2d7a0e7b0439", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/document-fragment.ts": "a40c6e18dd0efcf749a31552c1c9a6f7fa614452245e86ee38fc92ba0235e5ae", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/document.ts": "b8f4e4ccabaaa063d6562a0f2f8dea9c0419515d63d8bd79bfde95f7cd64bd93", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/dom-parser.ts": "609097b426f8c2358f3e5d2bca55ed026cf26cdf86562e94130dfdb0f2537f92", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/element.ts": "77c454e228dfeb5c570da5aa61d91850400116bfa0f5a85505acdd3c667171a4", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/elements/html-template-element.ts": "127bb291bb08afeb7e9a66294a5aa6ff2780f4eb4601fa6f7869fe8b70a81472", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/html-collection.ts": "ae90197f5270c32074926ad6cf30ee07d274d44596c7e413c354880cebce8565", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/node-list.ts": "4c6e4b4585301d4147addaccd90cb5f5a80e8d6290a1ba7058c5e3dfea16e15d", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/node.ts": "3069e6fc93ac4111a136ed68199d76673339842b9751610ba06f111ba7dc10a7", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/selectors/custom-api.ts": "852696bd58e534bc41bd3be9e2250b60b67cd95fd28ed16b1deff1d548531a71", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/selectors/nwsapi-types.ts": "c43b36c36acc5d32caabaa54fda8c9d239b2b0fcbce9a28efb93c84aa1021698", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/selectors/nwsapi.js": "985d7d8fc1eabbb88946b47a1c44c1b2d4aa79ff23c21424219f1528fa27a2ff", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/selectors/selectors.ts": "83eab57be2290fb48e3130533448c93c6c61239f2a2f3b85f1917f80ca0fdc75", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/selectors/sizzle-types.ts": "78149e2502409989ce861ed636b813b059e16bc267bb543e7c2b26ef43e4798b", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/selectors/sizzle.js": "c3aed60c1045a106d8e546ac2f85cc82e65f62d9af2f8f515210b9212286682a", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/utils-types.ts": "96db30e3e4a75b194201bb9fa30988215da7f91b380fca6a5143e51ece2a8436", + "https://deno.land/x/deno_dom@v0.1.38/src/dom/utils.ts": "55f3e9dc71d6c4a54605888d3f99d26fb0cf9973924709f159252a6933ceeabe", + "https://deno.land/x/deno_dom@v0.1.38/src/parser.ts": "b65eb7e673fa7ca611de871de109655f0aa9fa35ddc1de73df1a5fc2baafc332", + "https://deno.land/x/deno_graph@0.26.0/lib/deno_graph.generated.js": "2f7ca85b2ceb80ec4b3d1b7f3a504956083258610c7b9a1246238c5b7c68f62d", + "https://deno.land/x/deno_graph@0.26.0/lib/loader.ts": "380e37e71d0649eb50176a9786795988fc3c47063a520a54b616d7727b0f8629", + "https://deno.land/x/deno_graph@0.26.0/lib/media_type.ts": "222626d524fa2f9ebcc0ec7c7a7d5dfc74cc401cc46790f7c5e0eab0b0787707", + "https://deno.land/x/deno_graph@0.26.0/lib/snippets/deno_graph-de651bc9c240ed8d/src/deno_apis.js": "41192baaa550a5c6a146280fae358cede917ae16ec4e4315be51bef6631ca892", + "https://deno.land/x/deno_graph@0.26.0/mod.ts": "11131ae166580a1c7fa8506ff553751465a81c263d94443f18f353d0c320bc14", + "https://deno.land/x/deno_image@0.0.4/deps.ts": "ab0d77ff6a832f98fd47ca3eccc53c277c188768b9526743dbce76e8944ddceb", + "https://deno.land/x/deno_image@0.0.4/index.ts": "53a1ad6d0d1bfc158b2f4d774077e691d7384540bc93db74254ad32106982b7f", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/PNGDecoder.ts": "9ab11e0e4081ae9beec33bc9fff2343d0cacd9f97e7493f8ecaa836b3855b4e6", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/PNGEncoder.ts": "d24f87fffed9f59ae65e51562d049db16bb73d81416de44f4e38923c552d076a", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/common.ts": "96a3ee4a7e5e2067d51fd89a60e3a25c34bde867629d576285078ce3619c0789", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/index.ts": "e6af07e10d88bdf988a723a7dd9bcc278f0e797374f2b8c74d9e76f134117121", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/internalTypes.ts": "aa00b83cb49aa2c6cc833e4f28476e6ed5b4a5995aaa3da465c54e3fe9df0acd", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/iobuffer/IOBuffer.ts": "a030ae19737ebb98576f3ffcb620ce63058b98fe0f44528440b94d973823face", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/iobuffer/utf8.ts": "c2241c9ac7a46ca0734ffc040c704c506d9447cafc1975ca65c574f0a9391d59", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/index.js": "315a2b1dc7709050d632dbea1c920a4dc405f7079e88494d217d12d7a0d7e285", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/deflate.js": "f019793490803b3ac8e0193dcb9c5787b394909182a09cb5125767e4031c3c0e", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/inflate.js": "7c07d6084f95159405356fd35a77d6c79f35ca19048bb8e35630477f6da3cd34", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/utils/common.js": "d4e0dfc23eece2ab062d53e2f9726c14675bfe20cd890eeceb87291f31c5439b", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/utils/strings.js": "93d014c89c3d72ae2de82b29ae2e93583c203c04a494886d1826095b30e2c4fe", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/adler32.js": "495ef7b1d93d1b12aacfa0f13ef8d7f8f90e2b0073942bfc01b15d2b54e7f5d1", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/constants.js": "a50cd695b9408e424c1c04d3d5b50c06051861beb8417d008c2a973073c285cc", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/crc32.js": "529262cc302562e854873480dfa2654ce9b7ee32474cbbf12526426928e001be", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/deflate.js": "29b5c80f4ee3df72dd267693069a7418fec4d3c00015453539cf8ea28dd8952c", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/gzheader.js": "7395e33c625c064dfcbee9b84f4ff77c47a94dda9e117e1aca749df96c5b76f9", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/inffast.js": "113e0dc8a0878880ac0b5437b66395309938f55b6036613f3bcc4cd2b0c95baa", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/inflate.js": "c3d6cfaf68a4c2d97d24e5bd7754622b59860fcd317a0f56cde5431e51ad5fed", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/inftrees.js": "53c160e37b83f2325b1d85b7927bfde7bdd680a85175affef0862e5bfe775681", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/messages.js": "ed9ad2f2379aab2cffa6692db2a5be3a2af30d36f3940339b053a10045d8a2a6", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/trees.js": "a2f17c8e20e57214076c1efa1bf9d1444543ce7b6de364f09cab9ec7a0a4a31b", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/pako/lib/zlib/zstream.js": "a4667e4b65cc69d31787cf1a695533e22e0b54e441d470524db6abe82bc29a25", + "https://deno.land/x/deno_image@0.0.4/lib/decoders/fast-png/types.ts": "1dd91035997af1ea8d92a8887b9c28401ccf5f453ea5442e542055d94ca92602", + "https://deno.land/x/deno_image@0.0.4/lib/resize/resize.js": "271fc007ae18b90628f1c9b213ac900875ccec31214fe9cfc338a467237acbdc", + "https://deno.land/x/deno_image@0.0.4/mime-type.ts": "f6cbba14a3a6503554113bf6f9acae8fd6112245e1e659a922828fc2acab1d1f", + "https://deno.land/x/deno_image@0.0.4/mod.ts": "ff0028d3593dc470ddddba3ee6701f340ade4e9af5a9068272539589b42d6d0a", + "https://deno.land/x/deno_image@0.0.4/types.ts": "2c731878a70c232a710fc1fb650a01765d87cb31dcd07dc1561875a5ad486d20", + "https://deno.land/x/dir@1.5.1/data_local_dir/mod.ts": "91eb1c4bfadfbeda30171007bac6d85aadacd43224a5ed721bbe56bc64e9eb66", + "https://deno.land/x/dir@1.5.2/cache_dir/mod.ts": "8a82889db79c547fbbd3536c9c964047657b19fb59365c5fa59afc46082f9fe5", + "https://deno.land/x/dprint@0.2.0/mod.ts": "89ddd655e1b3a0b294f433c296fa01875037eed078b53aa60af755a25f128074", + "https://deno.land/x/emit@0.31.1/_utils.ts": "98412edc7aa29e77d592b54fbad00bdec1b05d0c25eb772a5f8edc9813e08d88", + "https://deno.land/x/emit@0.31.1/emit.generated.js": "67fe6a4eef452754b0d2203170ce69fcb26cb7f2170b574ec58953737e13d524", + "https://deno.land/x/emit@0.31.1/mod.ts": "326c48e48f3f3c83c11b089aec803e6270bcd64493f250aeb56f46f6960a8458", + "https://deno.land/x/jpegts@1.1/lib/decoder.ts": "b8823ee917fc99a1e085c353b2cd4ca1fbaacaaf1f80be94175e42487666c822", + "https://deno.land/x/jpegts@1.1/lib/encoder.ts": "d75fc5ae88f77e1fa349af08d7a46937add31a03b6d3605ff4d2e3303eb32b3b", + "https://deno.land/x/jpegts@1.1/lib/image.ts": "32255e99b6c1bf4e72e13367516a0d878ba9d195d81fac1f145ce4c11e951962", + "https://deno.land/x/jpegts@1.1/lib/pixel.ts": "19f7f28f09514157be87d01d1d12915cc17c787d8c1707899d2f5570ac6a12fe", + "https://deno.land/x/jpegts@1.1/mod.ts": "2014257f7269bcc822a4d6eb871e5002b347f13a641c12d45c1a61586374f127", + "https://deno.land/x/progress@v1.3.9/deps.ts": "83050e627263931d853ba28b7c15c80bf4be912bea7e0d3d13da2bc0aaf7889d", + "https://deno.land/x/progress@v1.3.9/mod.ts": "ca14ba3c56fc5991c4beee622faeb882e59db8f28d4f5e529ac17b7b07d55741", + "https://deno.land/x/progress@v1.3.9/multi.ts": "1de7edf67047ba0050edf63229970f2cbf9cd069342fcd29444a3800d4cfdcbc", + "https://deno.land/x/progress@v1.3.9/time.ts": "f5b302425cef076958c9352030f48a79bd7b54316b4057f99192884b5e140ea0", + "https://deno.land/x/wasmbuild@0.15.1/cache.ts": "9d01b5cb24e7f2a942bbd8d14b093751fa690a6cde8e21709ddc97667e6669ed", + "https://deno.land/x/wasmbuild@0.15.1/loader.ts": "8c2fc10e21678e42f84c5135d8ab6ab7dc92424c3f05d2354896a29ccfd02a63", + "https://deno.land/x/zipjs@v2.7.29/index.js": "7c71926e0c9618e48a22d9dce701131704fd3148a1d2eefd5dba1d786c846a5f", + "https://deno.land/x/zipjs@v2.7.29/lib/core/codec-pool.js": "e5ab8ee3ec800ed751ef1c63a1bd8e50f162aa256a5f625d173d7a32e76e828c", + "https://deno.land/x/zipjs@v2.7.29/lib/core/codec-worker.js": "744b7e149df6f2d105afbcb9cce573df2fbf7bf1c2e14c3689220c2dedeabe65", + "https://deno.land/x/zipjs@v2.7.29/lib/core/configuration.js": "baa316a63df2f8239f9d52cd4863eaedaddd34ad887b7513588da75d19e84932", + "https://deno.land/x/zipjs@v2.7.29/lib/core/constants.js": "14fe1468b87cd0fe20c6f1fec916485f875d8592beba94c9241af4cbd12dd88f", + "https://deno.land/x/zipjs@v2.7.29/lib/core/io.js": "4c4e86ba187540be533003271f222183455897cd144cb542539e9480882c2dda", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/aes-crypto-stream.js": "63988c9f3ce1e043c80e6eb140ebb07bf2ab543ee9a85349651ab74b96aab2cf", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/codec-stream.js": "685f1120b94b6295dcd61b195d6202cd24a5344e4588dc52f42e8ac0f9dfe294", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/codecs/crc32.js": "dfdde666f72b4a5ffc8cf5b1451e0db578ce4bd90de20df2cff5bfd47758cb23", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/codecs/deflate.js": "08c1b24d1845528f6db296570d690ecbe23c6c01c6cb26b561e601e770281c3a", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/codecs/inflate.js": "55d00eed332cf2c4f61e2ee23133e3257768d0608572ee3f9641a2921c3a6f67", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/codecs/sjcl.js": "462289c5312f01bba8a757a7a0f3d8f349f471183cb4c49fb73d58bba18a5428", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/common-crypto.js": "4d462619848d94427fcd486fd94e5c0741af60e476df6720da8224b086eba47e", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/crc32-stream.js": "10e26bd18df0e1e89d61a62827a1a1c19f4e541636dd0eccbd85af3afabce289", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/stream-adapter.js": "9e7f3fe1601cc447943cd37b5adb6d74c6e9c404d002e707e8eace7bc048929c", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/zip-crypto-stream.js": "19305af1e8296e7fa6763f3391d0b8149a1e09c659e1d1ff32a484448b18243c", + "https://deno.land/x/zipjs@v2.7.29/lib/core/streams/zip-entry-stream.js": "01d4dc0843e8c43d32454cbb15e4d1f9b7122ab288d7650129d010df54bc0b8e", + "https://deno.land/x/zipjs@v2.7.29/lib/core/util/cp437-decode.js": "d665ded184037ffe5d255be8f379f90416053e3d0d84fac95b28f4aeaab3d336", + "https://deno.land/x/zipjs@v2.7.29/lib/core/util/decode-text.js": "c04a098fa7c16470c48b6abd4eb4ac48af53547de65e7c8f39b78ae62330ad57", + "https://deno.land/x/zipjs@v2.7.29/lib/core/util/default-mime-type.js": "177ae00e1956d3d00cdefc40eb158cb591d3d24ede452c056d30f98d73d9cd73", + "https://deno.land/x/zipjs@v2.7.29/lib/core/util/encode-text.js": "c51a8947c15b7fe31b0036b69fd68817f54b30ce29502b5c9609d8b15e3b20d9", + "https://deno.land/x/zipjs@v2.7.29/lib/core/util/mime-type.js": "6c6dfa4daf98ef59cd65118073b74f327ceab2ef28140e38934b0d15eb2b5c29", + "https://deno.land/x/zipjs@v2.7.29/lib/core/util/stream-codec-shim.js": "1323016ec3c743942dc887215832badc7f2c1e8dbb37b71c94bf54276d2b281a", + "https://deno.land/x/zipjs@v2.7.29/lib/core/zip-entry.js": "d30a535cd1e75ef98094cd04120f178c103cdc4055d23ff747ffc6a154da8d2d", + "https://deno.land/x/zipjs@v2.7.29/lib/core/zip-fs-core.js": "737a92a0e27083eefa2b51811dd0b9b6ffc216b3949509fbc8833bfd2a78c071", + "https://deno.land/x/zipjs@v2.7.29/lib/core/zip-reader.js": "c918875362d7e46fc690cea0b3f81e50a0ec0b6e20f6ae35e5982de73ae45449", + "https://deno.land/x/zipjs@v2.7.29/lib/core/zip-writer.js": "b78c099828ec3134983c259adc4d6118fbfda7f033a7e95de8176a470e9a5a54", + "https://deno.land/x/zipjs@v2.7.29/lib/z-worker-inline.js": "a38bbe15eb011daee291b8243c3101b197aa91dcd786f1c04251bd1dda5c33e7", + "https://deno.land/x/zipjs@v2.7.29/lib/zip-fs.js": "a733360302f5fbec9cc01543cb9fcfe7bae3f35a50d0006626ce42fe8183b63f", + "https://deno.land/x/zod@v3.21.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", + "https://deno.land/x/zod@v3.21.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.21.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.21.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.21.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.21.4/helpers/parseUtil.ts": "51a76c126ee212be86013d53a9d07f87e9ae04bb1496f2558e61b62cb74a6aa8", + "https://deno.land/x/zod@v3.21.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.21.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.21.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774", + "https://deno.land/x/zod@v3.21.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.21.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.21.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", + "https://deno.land/x/zod@v3.21.4/types.ts": "b5d061babea250de14fc63764df5b3afa24f2b088a1d797fc060ba49a0ddff28", + "https://esm.sh/@actions/core@1.10.1?pin=v133": "deddb979c6ee7c39188f134612ed513b602a2d74aedbd92174636487ead84d34", + "https://esm.sh/@actions/github@5.1.1?pin=v133": "2d303133afdfd29b55225d75b8238a4a20da8442f868a6bf5fdd594d20fb04a8", + "https://esm.sh/@actions/github@6.0.0?pin=v133": "711eb3096db6aa606fa94322dbfac6b58ba3851d37ed41ea38fbff44f60b3e79", + "https://esm.sh/@faker-js/faker@8.0.2?pin=v133": "272bdba2649ecaef7356ed4917bcbec39dc2f6d43ed625d95475edd219d8df23", + "https://esm.sh/@octokit/app@14.0.0?pin=v133": "187d5a9993a63195fc5de0681eb89c3b35158bc18237dcba7728afcdf9cb9d18", + "https://esm.sh/@octokit/plugin-paginate-graphql@4.0.0?pin=v133": "a795271a6b05554d9e4da2c01455684b39abbc0c9c926b48b154425180409724", + "https://esm.sh/@octokit/rest@20.0.1?pin=v133": "a806c35826e5eced5852ed5983f17ec170b28e041341b9ef3bbed4d17bd51ccb", + "https://esm.sh/@octokit/types@11.1.0?pin=v133": "fabf6997a3adf11e45d11e3c4d58d0634ab98f9d72f3116f3694123818ccd457", + "https://esm.sh/@primer/octicons@19.7.0?pin=v133": "d71c27ddf374b1256d876d22305494df5d7d8cb489fcd3655a83cd08ad834c79", + "https://esm.sh/@twemoji/parser@14.1.0?pin=v133": "1d3f072d84b9551adab20fec10063f3f1befa30662330ba98865881037bae926", + "https://esm.sh/alpinejs@3.13.0?pin=v133": "d58e2a71f9c9a8d4982763829195ecd90732480a6e2a46b6a551a2471a4bfd35", + "https://esm.sh/chai-as-promised@7.1.1?pin=v133": "c94bdef9e8697e6cca465af6ec3437cea7cf53e44ae62e8a869f976d0ab3f2c7", + "https://esm.sh/chai-subset@1.6.0?pin=v133": "ac8deacc34a157422f66326849f0c7b859fab7362b07c520cf8035b3965080e7", + "https://esm.sh/chai@4.3.10?pin=v133": "e25839044ba92464bd0194939276cc97cbdec7f42ad2c49609d0092eaac1c8e1", + "https://esm.sh/csso@5.0.5?pin=v133": "3842f334e8832cfc311a226d2a29c67d25d2444da02dceb5bcf7130c164cdbbb", + "https://esm.sh/d3@7.8.5?pin=v133": "aff1bce084aaeff70f8321555468766d6f3cf838fa7a3a6f8ab8a3874d90b339", + "https://esm.sh/ejs@3.1.9?pin=v133": "11987a85eed88dc3354455d74cb5fb4caf09cab6eb692721813e00ec4ddb9678", + "https://esm.sh/highlight.js@11.8.0/lib/core?pin=v133": "767186d6baf7d4109f644958b151c3481d36a800a6b183dfc7c9dea38b111da8", + "https://esm.sh/highlight.js@11.8.0/lib/languages/diff?pin=v133": "8f32dc511477aa5c969db00f656787451fe58fa67652dafcde10e3289e3630d0", + "https://esm.sh/highlight.js@11.8.0/lib/languages/markdown?pin=v133": "b668b58186f94b24103b597b190664e9544b02a548dfe6ab4b5bff9484ff2e32", + "https://esm.sh/highlight.js@11.8.0/lib/languages/typescript?pin=v133": "a1cb242d2a98d87e963256548033316805567e937338f6e03eb99291c97aff0f", + "https://esm.sh/highlight.js@11.8.0/lib/languages/xml?pin=v133": "62d9498151a2c6f40ef0a6df7a5453cb75df2ee9763791e18a01d2aeceb9a9f1", + "https://esm.sh/highlight.js@11.8.0/lib/languages/yaml?pin=v133": "8b9e566ea2b4f57e5dc689b8c837aa502356eb9f39193dc7e910659fa7305ec8", + "https://esm.sh/html-escaper@3.0.3?pin=v133": "3854bbc6c3c68b0e650638b241fa2b5aac7ad002f01459b90ee8b934ecada799", + "https://esm.sh/human-format@1.2.0?pin=v133": "0ece93e94f5957f60b236230a2b45da0c1230dfacf10d8b40737b8e122dc02b9", + "https://esm.sh/linkedom@0.16.4?pin=v133": "762d712ab7db7a33e10c5335c8f4be49382c1134dd6a1622aa31f460c4abd92d", + "https://esm.sh/pluralize@8.0.0?pin=v133": "57d769a6fa62748ce1c09d918c21772ce92908dc366c8aaabcde29a4c21bcfb8", + "https://esm.sh/purgecss@5.0.0?pin=v133": "421058953fd85adfca2e6541ce6fcb3e97f44a92a1cec56abd3e143b74ede8e3", + "https://esm.sh/rehype-stringify@10.0.0?pin=v133": "dd7257483e850e3f9b12f3d3a63001e47b52801c9065935a24ab48ef8e5414a1", + "https://esm.sh/remark-gfm@4.0.0?pin=v133": "fa5d087bb50a0e427d5d33e0c4eb3e7a9455868a3164ece97064712981ffc5df", + "https://esm.sh/remark-parse@11.0.0?pin=v133": "4ef4281ab5905ec27cccc541b0b8eee92d4bbd1dfedc4a0180c273f46c165994", + "https://esm.sh/remark-rehype@11.0.0?pin=v133": "3a16e7e344b79c09983bb8e2d0b3969a410e59c6719940bba01ba1901f0d0562", + "https://esm.sh/rss-parser@3.13.0?pin=v133": "f8cd3d7e5d265c8ac4375b02265e068dc0edfbcaf636536c5246d67d70db2469", + "https://esm.sh/sanitize-html@2.11.0?pin=v133": "5eff582d9275a94ef40f7620f45384eff58797d01a4e3dd76651d4f7af7e0a19", + "https://esm.sh/string-argv@0.3.1?pin=v133": "aa97f90fbf02b0bccb7537a5123610a1796f1db8e5cccea8b59eb22ec3a2079b", + "https://esm.sh/svgo@3.0.2?pin=v133": "57dee35934ccbbcdbb57cba30a8d53facec9b9c06379a9639e9cba2d4f2c196c", + "https://esm.sh/unified@11.0.4?pin=v133": "3231f40ee8b1271cd4bdf777bab30d406dc7661859204704c4fac0ffbb72df91", + "https://esm.sh/v133/@actions/core@1.10.1/denonext/core.mjs": "c87946cb2c44bbc73e8cc502fd907afe393c4a1996e6e55c773fc7f7774a6578", + "https://esm.sh/v133/@actions/github@5.1.1/denonext/github.mjs": "fb9d97bbd0b1619df73d036fdbc09b788cbc0ce5f403e682996c4905f492b01e", + "https://esm.sh/v133/@actions/github@6.0.0/denonext/github.mjs": "6506df85a04126d2bec34933a05709782c65780e5d3e9e17888d27d1e4b063e7", + "https://esm.sh/v133/@actions/http-client@2.2.0/denonext/http-client.mjs": "0f018f1a1ac091fd61068b8ef3f5da1fe3daac262e9f7fdb6ae1b9fd84f16715", + "https://esm.sh/v133/@actions/http-client@2.2.0/denonext/lib/auth.js": "bdf8aa70bd820a1a4fb3f9f71728e72b1d5aca36406598354b89e6a944d0aec5", + "https://esm.sh/v133/@faker-js/faker@8.0.2/denonext/faker.mjs": "870def5d50e632211a71722109fae486a00080a14a2329589ea4a83f878b6699", + "https://esm.sh/v133/@fastify/busboy@2.0.0/denonext/busboy.mjs": "8e80ab22a76eb19ab186d3d285bea979e78392328a835fe41eb56b47d837fd7c", + "https://esm.sh/v133/@octokit/app@14.0.0/denonext/app.mjs": "8eaaa31d4d88e216ed842c964d7e6ff371bfa026d9464d4c34c8e216ef516a81", + "https://esm.sh/v133/@octokit/auth-app@6.0.1/denonext/auth-app.mjs": "5b4acdb7cbd68dcfc5c80a73da0e91e784605cd7c908209c6803de7fb9bd4b5d", + "https://esm.sh/v133/@octokit/auth-oauth-app@7.0.1/denonext/auth-oauth-app.mjs": "1e048c9a851bb2077f20f32c0439f48f09a15545a0cbcf1e6b1a576112f90a61", + "https://esm.sh/v133/@octokit/auth-oauth-device@6.0.1/denonext/auth-oauth-device.mjs": "035d69b58bb56996853f66f2ed6468ab268224c1675999a7734391283c08a331", + "https://esm.sh/v133/@octokit/auth-oauth-user@4.0.1/denonext/auth-oauth-user.mjs": "5ea1c2b5b454df1fc06e42ac6d90ab5b0f8d0ee17931c7d91bbb6a70d8c95c2a", + "https://esm.sh/v133/@octokit/auth-token@2.5.0/denonext/auth-token.mjs": "a7f067796a1a6e00422c3903511a25d4406d1e2396c815b0cd2b34e8b34bb2a1", + "https://esm.sh/v133/@octokit/auth-token@4.0.0/denonext/auth-token.mjs": "ab9285959f2bc3ca7942bfb81df3e2f15f5895c829455156647faa403b75703b", + "https://esm.sh/v133/@octokit/auth-unauthenticated@5.0.1/denonext/auth-unauthenticated.mjs": "2ae0ee39a6c0f36dbfdf4af299a2f69a9cf2469e7e363f374fba7d896d7da640", + "https://esm.sh/v133/@octokit/core@3.6.0/denonext/core.mjs": "9eef2e77c43ac6ce2baeeabb8c37376273fc74184cccc9ba73ac32330461d93e", + "https://esm.sh/v133/@octokit/core@5.0.1/denonext/core.mjs": "8cf6b3b0356241eb748933f0f1ef88e3b28e3a412d17cc236f7c126a0940d830", + "https://esm.sh/v133/@octokit/endpoint@6.0.12/denonext/endpoint.mjs": "ca27671422195a7fcb1f995f1a1b4bbf4ea89fd4ef29f5110a769913ef96430a", + "https://esm.sh/v133/@octokit/endpoint@9.0.1/denonext/endpoint.mjs": "67cedafe7cf0102a05314232e2bc807bfdb9ead23b4bf1012364b2418edcc086", + "https://esm.sh/v133/@octokit/graphql@4.8.0/denonext/graphql.mjs": "67064afbaf1d827658a146113ca7d1e620bb22c5a5de43ba6f499cc37242b974", + "https://esm.sh/v133/@octokit/graphql@7.0.2/denonext/graphql.mjs": "1a1febedce4d2215b19c0acc2777d49f5ff5ed5a4fff69b698d30bbad56cb6e3", + "https://esm.sh/v133/@octokit/oauth-app@6.0.0/denonext/oauth-app.mjs": "4132fec67281cf007955872dbb851eb56aec6f4e7bf25b1ff41fb9a5b155066a", + "https://esm.sh/v133/@octokit/oauth-authorization-url@6.0.2/denonext/oauth-authorization-url.mjs": "c4a3809c1092ab4135bbb65ba82e885b915a3e0b68e8c9554c98766388baa399", + "https://esm.sh/v133/@octokit/oauth-methods@4.0.0/denonext/oauth-methods.mjs": "b832f32de9162fb86221ebb7a2256e0183d7e0b850a9aa9768d75208db4cb88c", + "https://esm.sh/v133/@octokit/plugin-paginate-graphql@4.0.0/denonext/plugin-paginate-graphql.mjs": "6d2abafdadec31dc3afb9308c18a44b51b834357011d67dce611817196ac032f", + "https://esm.sh/v133/@octokit/plugin-paginate-rest@2.21.3/denonext/plugin-paginate-rest.mjs": "28a049516c0c4f831ed504697cf7f4765038c06b071d577a3b9f63b17edfeb8a", + "https://esm.sh/v133/@octokit/plugin-paginate-rest@8.0.0/denonext/plugin-paginate-rest.mjs": "8ed6e17dcb65d460c99fe3c3133c20b566fa2c7c4aedcf931e795ea2ca46e24f", + "https://esm.sh/v133/@octokit/plugin-paginate-rest@9.0.0/denonext/plugin-paginate-rest.mjs": "aa8290a0835e2acb92abaca27d33d5991d5fe6d2d1bc9ddbd806640879d1ef62", + "https://esm.sh/v133/@octokit/plugin-request-log@4.0.0/denonext/plugin-request-log.mjs": "8683bc54297e4657259a0edc6abe2c8cf9953c1b044faa8cfb1a9bc0970b54a3", + "https://esm.sh/v133/@octokit/plugin-rest-endpoint-methods@10.0.1/denonext/plugin-rest-endpoint-methods.mjs": "d2040845ed9c409b5effe86152c6da6b163a21e52ad58d7de5e5528d4f9cc9bf", + "https://esm.sh/v133/@octokit/plugin-rest-endpoint-methods@5.16.2/denonext/plugin-rest-endpoint-methods.mjs": "02504fb911e19696fe1d10fb65c071e96b3744c1b066b98836fdb2a4aeb4fa14", + "https://esm.sh/v133/@octokit/plugin-rest-endpoint-methods@9.0.0/denonext/plugin-rest-endpoint-methods.mjs": "84a5baa44ee16c3ffe9347291fbc106aefa5e57c48b31eacf6bb0ca60b7ad5cd", + "https://esm.sh/v133/@octokit/request-error@2.1.0/denonext/request-error.mjs": "e8eafba19c506909078a3a834ad6e7dc8d7193ae8d85833ba1e38b042d27e7fb", + "https://esm.sh/v133/@octokit/request-error@5.0.1/denonext/request-error.mjs": "1470a2877881bd9696bbf59f9605a654e44421edc2926511a86af554d649587a", + "https://esm.sh/v133/@octokit/request@5.6.3/denonext/request.mjs": "69ad09cc4f16cffeda9edc64154bc64874193b7018e225152d1adceec8057de2", + "https://esm.sh/v133/@octokit/request@8.1.3/denonext/request.mjs": "b6819980aef7da54b85a943ec6b87e402be3baebb1536ec54090ba834f01da60", + "https://esm.sh/v133/@octokit/rest@20.0.1/denonext/rest.mjs": "387f0e462f13809adc12b41f32ed0bb1463e7b07288ba1fb83c3d9fa8f9a07ed", + "https://esm.sh/v133/@octokit/webhooks-methods@4.0.0/denonext/webhooks-methods.mjs": "b44d80a07e6e698c95a2fc2789e2caf10b4ff821300e947b2e79c449453a7a19", + "https://esm.sh/v133/@octokit/webhooks@12.0.3/denonext/webhooks.mjs": "ab2e6cec55c57d6042996ed13fe667e803dadd778c900071c2d399944470194c", + "https://esm.sh/v133/@primer/octicons@19.7.0/denonext/octicons.mjs": "fac114048b389625024008b8e689aa47590a2d7d399dd968de699b9942c2c1bd", + "https://esm.sh/v133/@trysound/sax@0.2.0/denonext/sax.mjs": "5af2a1f55d3d7b89542b1412e592e50a4da57906ffa256d2ec17ee56d8876d6c", + "https://esm.sh/v133/@twemoji/parser@14.1.0/denonext/parser.mjs": "49be06f8aace91c31d62ab861d019657bdbed8fac3491a2ec2521ebada375139", + "https://esm.sh/v133/@ungap/structured-clone@1.2.0/denonext/structured-clone.mjs": "e683ab48ef7a3afd3bce9d1589d14177ddbdbf76fa1483524dddbeb6b142469f", + "https://esm.sh/v133/aggregate-error@3.1.0/denonext/aggregate-error.mjs": "d236bc1eaaa1a49e385e926dd8cb88f10ceca9a193aa673767d02ed6a79ee2a3", + "https://esm.sh/v133/alpinejs@3.13.0/denonext/alpinejs.mjs": "fc7a8d3ed5384c2f7f722812ddb98991461740ede9c0d4e49c63383697909c0c", + "https://esm.sh/v133/assertion-error@1.1.0/denonext/assertion-error.mjs": "0a4a5dccfb89070dd1e09fad036e706aa51d9dd3236ab019aed08bef1841695b", + "https://esm.sh/v133/bail@2.0.2/denonext/bail.mjs": "cab740cf24417dfad8567cbb78a49ae7e492ecca299f2b3185520805aba4d073", + "https://esm.sh/v133/balanced-match@1.0.2/denonext/balanced-match.mjs": "b2f9737a6fb330aedd4a444eb85ba127d757c49032a528400fc0e2efb70ddca3", + "https://esm.sh/v133/before-after-hook@2.2.3/denonext/before-after-hook.mjs": "f4262d059d899d7fcaa8d903bcf352df38ac1c040bb45273d79de200ffdad267", + "https://esm.sh/v133/boolbase@1.0.0/denonext/boolbase.mjs": "4e3bd67e9b1c5c55094eae98345d0107c6a44ef57bd3d4b9579698fa44722280", + "https://esm.sh/v133/brace-expansion@2.0.1/denonext/brace-expansion.mjs": "349195414421fb06e01312079ad83b4876200d9ca94378b7628508322cf6ce07", + "https://esm.sh/v133/btoa-lite@1.0.0/denonext/btoa-lite.mjs": "5f8ecdf77c8824225e30cf84b62ded0fa667ef51fe1a8b772b1ddf46410bf78f", + "https://esm.sh/v133/canvas@2.11.2/denonext/canvas.mjs": "4245b1d01d91b5e807b85e40e98efe28c93634260bd8cb5ac0da71c42098a1a4", + "https://esm.sh/v133/ccount@2.0.1/denonext/ccount.mjs": "7b32092651a866fcc992c028982ce5e911356da7653baa3febb1a8ccb93e30f8", + "https://esm.sh/v133/chai-as-promised@7.1.1/denonext/chai-as-promised.mjs": "bbcd90c4502fe553f17aa1923cb07c22cb2302c1da1d50a046796ab71b9f7f2e", + "https://esm.sh/v133/chai-subset@1.6.0/denonext/chai-subset.mjs": "009fd018f8bd802311ca02092056f1c25b2e84888c742cb14667b28ad308f041", + "https://esm.sh/v133/chai@4.3.10/denonext/chai.mjs": "fa4ea11c224f9f3abc5272c8917c0c629ec5ae2bec2fafe4edee09dfddcc4f68", + "https://esm.sh/v133/character-entities-html4@2.1.0/denonext/character-entities-html4.mjs": "0b4e64d1b0152acbeec6d854eadce6ceb2de05b0f459ad47485afa206f745f10", + "https://esm.sh/v133/character-entities-legacy@3.0.0/denonext/character-entities-legacy.mjs": "5da76ada1554e4956dc6b702ba92b56a3faf158b24bf45279c522e85f5d9cd21", + "https://esm.sh/v133/character-entities@2.0.2/denonext/character-entities.mjs": "9e8657f056310ac3ca8058eaf96cef695ee13a4bf6c302674796a882464f305c", + "https://esm.sh/v133/check-error@1.0.3/denonext/check-error.mjs": "04b0b4e7d4470a991f1211e35075d68ad3d96602236853f615527af0e889a265", + "https://esm.sh/v133/clean-stack@2.2.0/denonext/clean-stack.mjs": "8a2732fd195c0663d9f8a3957932215f3d4cb45dbe2f4747c9f39a966a4f067b", + "https://esm.sh/v133/comma-separated-tokens@2.0.3/denonext/comma-separated-tokens.mjs": "ad5df8a36487e0a63d15bbbb6bab8b153e08583d0d5eb6d0058cd0fc619252e0", + "https://esm.sh/v133/css-select@5.1.0/denonext/css-select.mjs": "6cb15a647fb91f97ab1bc4bb5db5af8b8a7e73598049055c8d57ef1f75ceb933", + "https://esm.sh/v133/css-tree@2.2.1/denonext/css-tree.mjs": "e44c8461dc8920cd5bd1b5add434420ff48567e8f5272fd912ee5a2a570c1ea2", + "https://esm.sh/v133/css-tree@2.3.1/denonext/css-tree.mjs": "71e99f8ea21f065f8538a73d0328cab790edaac335c1b638d42faeddb6837f67", + "https://esm.sh/v133/css-what@6.1.0/denonext/css-what.mjs": "283d02df6fef73d3223b55324b559b363dd0e4c008ea1efbcf9f14b8f2642202", + "https://esm.sh/v133/cssesc@3.0.0/denonext/cssesc.mjs": "8e1441106b9e0d6a2b1c5f012f9f22962fbd8661490c06e7d0f602dd2b65864f", + "https://esm.sh/v133/csso@5.0.5/denonext/csso.mjs": "4bfb3db78826e7f1a1ad7d4a2c25fb2c769e73d860693efe1a4a06a05d7ea083", + "https://esm.sh/v133/cssom@0.5.0/denonext/cssom.mjs": "c4216df78cba2c7be94853258b6b28c681e60ff89d4aeb3149b0ac91302be428", + "https://esm.sh/v133/d3-array@3.2.4/denonext/d3-array.mjs": "c89e8c7acc54ab109ce5a60320059d9ffd5d6d473419aefef91b1a735083e3e8", + "https://esm.sh/v133/d3-axis@3.0.0/denonext/d3-axis.mjs": "7a6271f6a866e2d29f1bb615174bde71522ada95aa0d67a44b312116256ac6be", + "https://esm.sh/v133/d3-brush@3.0.0/denonext/d3-brush.mjs": "ba18503fbcfbc4492d7797778186df0f04d165f34b4fa1df3edb52699f760f67", + "https://esm.sh/v133/d3-chord@3.0.1/denonext/d3-chord.mjs": "5c493570e6c7c2da1781a128ef56a55608bf6ec91401f09c9506d204b49f7a05", + "https://esm.sh/v133/d3-color@3.1.0/denonext/d3-color.mjs": "122fc5641a8e7614e350502c67047a165dc2bf355b50f140a2eee1c785c162c9", + "https://esm.sh/v133/d3-contour@4.0.2/denonext/d3-contour.mjs": "4b97b8d62d3af0afcaf9ab27871f2c242795dabcc1313b6333dd459f679429a0", + "https://esm.sh/v133/d3-delaunay@6.0.4/denonext/d3-delaunay.mjs": "c853e6459a30a06f8093c3c00bf2494d261786c62e38d80b4938d582e82d561a", + "https://esm.sh/v133/d3-dispatch@3.0.1/denonext/d3-dispatch.mjs": "7715a9e7ce668c8f874bc280d093e9017c1432829086a3e2122082f31a5d5c69", + "https://esm.sh/v133/d3-drag@3.0.0/denonext/d3-drag.mjs": "af85f36bb1106aa28873e3c8c75a6dbed42365e194d84504958845dba3273449", + "https://esm.sh/v133/d3-dsv@3.0.1/denonext/d3-dsv.mjs": "bff61331c5dcae3863f719387ce26e391e51b704bbd244d3ca4f44543b135bd5", + "https://esm.sh/v133/d3-ease@3.0.1/denonext/d3-ease.mjs": "a75365d6c9dc21ab661fc4b1fa6c9fb35a317750b8c624358898c8081ecbf80b", + "https://esm.sh/v133/d3-fetch@3.0.1/denonext/d3-fetch.mjs": "1a9b1586fa66ca4a595aa4ab6ac6d32d08c68cecdbc8451e0be8f70c4ceadde9", + "https://esm.sh/v133/d3-force@3.0.0/denonext/d3-force.mjs": "39688899f82ca141f25dcf2fcdd47bc20a79026155a497560f9045b3d87417cd", + "https://esm.sh/v133/d3-format@3.1.0/denonext/d3-format.mjs": "7527b9b146f53c0673442534996b432e496273f14a40c35778c2db5636d78e6d", + "https://esm.sh/v133/d3-geo@3.1.0/denonext/d3-geo.mjs": "73ca919b61a69f4bcfba0021a95b157f1ce1a4ee06c48fe19036455e11c48f00", + "https://esm.sh/v133/d3-hierarchy@3.1.2/denonext/d3-hierarchy.mjs": "d57cfe827b4873a6b9b25b3543092a33d68206718b53bec53ac89331bcbed09f", + "https://esm.sh/v133/d3-interpolate@3.0.1/denonext/d3-interpolate.mjs": "a1f397f822988c841c97fcf48e094c473ae69a199bb2deea1c732e2ccca4b822", + "https://esm.sh/v133/d3-path@3.1.0/denonext/d3-path.mjs": "38324963b0a585cef0359e7ac96792d5de75e2889f274378cbb1cac4a041a4a8", + "https://esm.sh/v133/d3-polygon@3.0.1/denonext/d3-polygon.mjs": "52f107c15baef4491dcf49e9539487bcf49e548049f903cea5afe6538b0063dd", + "https://esm.sh/v133/d3-quadtree@3.0.1/denonext/d3-quadtree.mjs": "9e571ae56723bdcbc04c302784a0b6260483ba1e04a40310b1ec72b0cd2d2378", + "https://esm.sh/v133/d3-random@3.0.1/denonext/d3-random.mjs": "3fa88dbce2d4ce933b46e4c6e70466788ef25077ea35e8120a6c1a5d6e2fbdbc", + "https://esm.sh/v133/d3-scale-chromatic@3.0.0/denonext/d3-scale-chromatic.mjs": "8e06a4ef7846d0837ee8f8e98a7ffa78b47db9deb953bd7d138e0f323355d2b6", + "https://esm.sh/v133/d3-scale@4.0.2/denonext/d3-scale.mjs": "db0fd453c9352505f1e50cfbee2012bf315a912d6be2ab6d3b5da29deb66f3bc", + "https://esm.sh/v133/d3-selection@3.0.0/denonext/d3-selection.mjs": "944c0f6ba003bc2f560f2ff4114fac04eccec6b32b73d2f6937c65017b8b56a3", + "https://esm.sh/v133/d3-shape@3.2.0/denonext/d3-shape.mjs": "36e054ed9979e40a97c4746c7d6be14fd213580211ed78c3b78b6e182019d244", + "https://esm.sh/v133/d3-time-format@4.1.0/denonext/d3-time-format.mjs": "5a23f8583bd8c3998e9135ffebac3155659691f67fa10b5b8ab813b72b01089d", + "https://esm.sh/v133/d3-time@3.1.0/denonext/d3-time.mjs": "9368bf3befe70ba84183d75434f7eb9ed011895bfd0cbfc6f0bfe9f2d66dec59", + "https://esm.sh/v133/d3-timer@3.0.1/denonext/d3-timer.mjs": "fb8f1715c2e808e427b87cafdc6ffd529ac7a707348e4a86ba3c15068de64ee3", + "https://esm.sh/v133/d3-transition@3.0.1/denonext/d3-transition.mjs": "a454f8c6172c852a4657f8db3e948ee7d9b0aeb6b40e3d2c95bea6a5423ee67c", + "https://esm.sh/v133/d3-zoom@3.0.0/denonext/d3-zoom.mjs": "51f9a205bc01f462698f21b3778b837a44f82fa89ed1df898aa8ecf4a8f9ebcd", + "https://esm.sh/v133/d3@7.8.5/denonext/d3.mjs": "ef2629df2fcde40097dbb71c67ab64296aaf06509c8544acaa5eece6c2ea39e1", + "https://esm.sh/v133/decode-named-character-reference@1.0.2/denonext/decode-named-character-reference.mjs": "3b4ca98e658070d76af130dde5eb10646025d97aba22894e5daa5ef7c2ac9f83", + "https://esm.sh/v133/deep-eql@4.1.3/denonext/deep-eql.mjs": "3f406af09e31cfb3d403689e277eb392ee18361ca682c26a3955db094ba94802", + "https://esm.sh/v133/deepmerge@4.3.1/denonext/deepmerge.mjs": "08481570f6011601fe4b827bffb00cdea632e55c923c44485d8719e8ab77b7c9", + "https://esm.sh/v133/delaunator@5.0.0/denonext/delaunator.mjs": "312f709adb7e20a965e3bd8a7ae025717ba79e76fd3917482719f8bf6e20327d", + "https://esm.sh/v133/deprecation@2.3.1/denonext/deprecation.mjs": "0bf7139d1068345709e59dddb4daea315691d290a8c896a6e076dea02dd66eaf", + "https://esm.sh/v133/devlop@1.1.0/denonext/devlop.mjs": "05fffa5a5168daec45963b784734dbc468758e130a340af874adfe0d457e394a", + "https://esm.sh/v133/dom-serializer@2.0.0/denonext/dom-serializer.mjs": "20abe83304a6d0bb2e584df59bc871ff81fbb6e6e6f7f8d0a5448e7aa95cb8c4", + "https://esm.sh/v133/domelementtype@2.3.0/denonext/domelementtype.mjs": "371936c356d5ac797f2ce3a66b98dfc73e6fff0e095b2858b85668e6ad7d10e0", + "https://esm.sh/v133/domhandler@5.0.3/denonext/domhandler.mjs": "5acfa6319b67a779446479c9d1f542a1510cfea3ef04d590af13c67e8d68127c", + "https://esm.sh/v133/domutils@3.1.0/denonext/domutils.mjs": "7f848df28d667aefd1e2450f24a7159e75f92a0287edbdaa81c0cfee269fe807", + "https://esm.sh/v133/ejs@3.1.9/denonext/ejs.mjs": "30fabad57b39bf90ea47cf5df77b683f4cca4622a438b47c2a1e73a077608127", + "https://esm.sh/v133/entities@2.2.0/denonext/entities.mjs": "af15975e7c8b5f17be658d1db80f9de7c076cb9cd53c4dcfc9dd60d460266d62", + "https://esm.sh/v133/entities@4.5.0/denonext/entities.mjs": "e491f306bcd67703da07e7a4364656fc109d55cc871422c282ed7829b8bfbfab", + "https://esm.sh/v133/entities@4.5.0/denonext/lib/decode.js": "7fea6d8bd725edbbf7ea05031d2ea1bbbc1166dc11e3345d541198dd2dc16f1e", + "https://esm.sh/v133/escape-string-regexp@4.0.0/denonext/escape-string-regexp.mjs": "4bebd5b21a97ea88f1b93f2851c077053d3cc323ac2b2eefc835ff3d7dab8ae9", + "https://esm.sh/v133/escape-string-regexp@5.0.0/denonext/escape-string-regexp.mjs": "6080dd39c43a11f999a41172a27b8c58572d747d8276c039d93d4be8b21747a5", + "https://esm.sh/v133/extend@3.0.2/denonext/extend.mjs": "6ab9e4890dfa54ec88fa326fca525c211141d6da72188f54e9610d1281219c9e", + "https://esm.sh/v133/fs.realpath@1.0.0/denonext/fs.realpath.mjs": "2d690bfa365fc259d42d6cb27744765ba8776b46f2c73dd0596b118b62846250", + "https://esm.sh/v133/get-func-name@2.0.2/denonext/get-func-name.mjs": "351ff55c7e8628cae42ebcbfe38ab07c2182a916cd7cbaa8b261700a47c92e81", + "https://esm.sh/v133/glob@8.1.0/denonext/glob.mjs": "53101653d7cbd7b55883e2aebc939ba4f1ea68ad0d8224596c85920f3eb7ce82", + "https://esm.sh/v133/hast-util-to-html@9.0.0/denonext/hast-util-to-html.mjs": "fece8dd86f757923b13f6c7defafeae205fb30393257927249faa271e1d71c96", + "https://esm.sh/v133/hast-util-whitespace@3.0.0/denonext/hast-util-whitespace.mjs": "b2988a87d03b42636bca6ebee778326993a23953ba6638973be17ce03100a357", + "https://esm.sh/v133/highlight.js@11.8.0/denonext/lib/core.js": "93781f7216bfad2a7bc6371ae535d479bd56e7a13bc1eb2a8984ad9b5cb4aa41", + "https://esm.sh/v133/highlight.js@11.8.0/denonext/lib/languages/diff.js": "10bc718a560c1e65a081e21eda9c9adfbc8cc1d06728f9aed67656422eb9e6bb", + "https://esm.sh/v133/highlight.js@11.8.0/denonext/lib/languages/markdown.js": "f174f5fdd7cd7d8035ac0b20f1c5121d5c79429d9c0e118367ac9d2d1a52aea3", + "https://esm.sh/v133/highlight.js@11.8.0/denonext/lib/languages/typescript.js": "e52bfa3ed32d40b62ba516b86ad03551b33cf111eb804b758e1d087071a653b2", + "https://esm.sh/v133/highlight.js@11.8.0/denonext/lib/languages/xml.js": "4b59f4adfa6f8d6bc6c590a0efdfcb7547a3d3c95940bbe0b28ff03797d98408", + "https://esm.sh/v133/highlight.js@11.8.0/denonext/lib/languages/yaml.js": "7f6bba0334b23dd3ba0eb84e29e4479528875e3781f8561cd2296c2f9a299c1b", + "https://esm.sh/v133/html-escaper@3.0.3/denonext/html-escaper.mjs": "c51d972a3e0a4560b4f2e81b42a71b68d986461c809d576c56d00e729d08258e", + "https://esm.sh/v133/html-void-elements@3.0.0/denonext/html-void-elements.mjs": "331b966249908a14e21bcbd584cc9f88d8df7d78a4bed94f002c9fd297d17459", + "https://esm.sh/v133/htmlparser2@8.0.2/denonext/htmlparser2.mjs": "4bc5b9d36bc0c2535a5016c559f55de7da9709d7c3d5fc533d779c3e7f9a1dec", + "https://esm.sh/v133/htmlparser2@9.0.0/denonext/htmlparser2.mjs": "d1c0ee518bb2966957394d51d84b1b1368bed2c39012ccd88be9cb638da69fd0", + "https://esm.sh/v133/human-format@1.2.0/denonext/human-format.mjs": "d767edbf69b662936013c764c680b80180e26b7851d633bbf5c0f28090bad4e6", + "https://esm.sh/v133/indent-string@4.0.0/denonext/indent-string.mjs": "de4038b836a42fe2a7c453bd0b44cdae706a9560bbef99b1857c13f9d55ace14", + "https://esm.sh/v133/inflight@1.0.6/denonext/inflight.mjs": "c9c109d09a88df67bbe01d0e111d8772c7950dfe2ff4f3c1882f81cc19fbd01f", + "https://esm.sh/v133/inherits@2.0.4/denonext/inherits.mjs": "8095f3d6aea060c904fb24ae50f2882779c0acbe5d56814514c8b5153f3b4b3b", + "https://esm.sh/v133/internmap@2.0.3/denonext/internmap.mjs": "c186a0bf924a737fc6e44f062fbe24e6a11c4f0643ce7c357dfd2eaf811648c6", + "https://esm.sh/v133/is-plain-obj@4.1.0/denonext/is-plain-obj.mjs": "d3d86a7174ad7935de7b00f904b6424c103bce530c502efb7f42114cbb1a555f", + "https://esm.sh/v133/is-plain-object@5.0.0/denonext/is-plain-object.mjs": "6d9568ddc8b90de99a46c63e14984810280b6b021dc4e478803b3c240811985f", + "https://esm.sh/v133/linkedom@0.16.4/denonext/linkedom.mjs": "f7eb32268e7f5b04ce6ef9e21d5a10f6f08b0bfbef57c4b827f6df38b3fcb3e6", + "https://esm.sh/v133/longest-streak@3.1.0/denonext/longest-streak.mjs": "97b1d8c42d407e285971fea218d89a0404270c3b841a26ec62b83f62450ad573", + "https://esm.sh/v133/loupe@2.3.6/denonext/loupe.mjs": "0a7ad4a5659cbe5b2130eb6b81d078b97d4a26a63fce3b3bf2632c098cf85c7c", + "https://esm.sh/v133/lru-cache@10.0.1/denonext/lru-cache.mjs": "0bf693a0bedd7806c7bf2f336f83ec6dd1933b881703cf59bee326573473aa35", + "https://esm.sh/v133/markdown-table@3.0.3/denonext/markdown-table.mjs": "1dd9cb2d2d95fc440cc10489ac77bf979c944e76070997d962e05f627f45df0e", + "https://esm.sh/v133/mdast-util-find-and-replace@3.0.1/denonext/mdast-util-find-and-replace.mjs": "e476b7a4e7d2c0ba316643177db2f952676fc2169d2d0f5f679aafadf7624dd1", + "https://esm.sh/v133/mdast-util-from-markdown@2.0.0/denonext/mdast-util-from-markdown.mjs": "db2824c4be085e2f749d4cfa383a8c49e86ac3e0ca09fce67c832e9f83a101bf", + "https://esm.sh/v133/mdast-util-gfm-autolink-literal@2.0.0/denonext/mdast-util-gfm-autolink-literal.mjs": "6f3e8769e89bf982320b5b65779f6f082657e913ea890e2d5a86d4ab4e83796d", + "https://esm.sh/v133/mdast-util-gfm-footnote@2.0.0/denonext/mdast-util-gfm-footnote.mjs": "8b6183fcb2ac0e691bfaa852e6e2a4a5db32c58edf3f2b2a908799951c022298", + "https://esm.sh/v133/mdast-util-gfm-strikethrough@2.0.0/denonext/mdast-util-gfm-strikethrough.mjs": "8d6128f5b2e3c6df5d5a17f5ef00a8d945218e591b3aa668cd146c9050b2664e", + "https://esm.sh/v133/mdast-util-gfm-table@2.0.0/denonext/mdast-util-gfm-table.mjs": "48ac659dca169f7a4a273d4364bf9dc96c77a8c1643a91004adccf046c93ea02", + "https://esm.sh/v133/mdast-util-gfm-task-list-item@2.0.0/denonext/mdast-util-gfm-task-list-item.mjs": "af6a0d16520b126c61887c5e60da02427ccebccbc4b711e15f2f7d71438e6eef", + "https://esm.sh/v133/mdast-util-gfm@3.0.0/denonext/mdast-util-gfm.mjs": "290625d1016797c32a1cb20ce0be8a82bceb004a52c6fc9b9b9fdca30c9c8e91", + "https://esm.sh/v133/mdast-util-phrasing@4.0.0/denonext/mdast-util-phrasing.mjs": "aeace916b07530904772d8bff2c3feb36536cd76f8968d4c4dfb931dabd18e7f", + "https://esm.sh/v133/mdast-util-to-hast@13.0.2/denonext/mdast-util-to-hast.mjs": "a8f8957fce85b3da8bd2182b1a311598c1dd2653f2d8c2b3076567b760c2de93", + "https://esm.sh/v133/mdast-util-to-markdown@2.1.0/denonext/mdast-util-to-markdown.mjs": "462a92995327a2113f2a13ebc39314413d3d7cde3f6414a30da4b4810c855584", + "https://esm.sh/v133/mdast-util-to-string@4.0.0/denonext/mdast-util-to-string.mjs": "eda9725fc0c7dc0e7b56998d2d8e4f29312cc5493cb7834c70f32fab2609103b", + "https://esm.sh/v133/micromark-core-commonmark@2.0.0/denonext/micromark-core-commonmark.mjs": "f7336bd137e87e681b5ec1f84336b99349c7f9136920699558422f31a024813b", + "https://esm.sh/v133/micromark-extension-gfm-autolink-literal@2.0.0/denonext/micromark-extension-gfm-autolink-literal.mjs": "5fba9284145ac9072a3fac8f089c83ff7453def33d5edb4e2a382683ce5e5832", + "https://esm.sh/v133/micromark-extension-gfm-footnote@2.0.0/denonext/micromark-extension-gfm-footnote.mjs": "b53ae38d6f936b4cc7ecb8450488793b7e2124a06ccb1b8ac93cf9331a11cb20", + "https://esm.sh/v133/micromark-extension-gfm-strikethrough@2.0.0/denonext/micromark-extension-gfm-strikethrough.mjs": "75bc4dc484e05beec2c90bae90c11c01bd5c923379ccaef3eb05f0782f10faa4", + "https://esm.sh/v133/micromark-extension-gfm-table@2.0.0/denonext/micromark-extension-gfm-table.mjs": "988b12010a6430246e830c9ee7d5d064a30c326366715f2efe87b260cf2e4d63", + "https://esm.sh/v133/micromark-extension-gfm-tagfilter@2.0.0/denonext/micromark-extension-gfm-tagfilter.mjs": "6e6faebfc624e00c5af52026393372174a7415aab2bc1d7cc16f2541f2977ba3", + "https://esm.sh/v133/micromark-extension-gfm-task-list-item@2.0.1/denonext/micromark-extension-gfm-task-list-item.mjs": "68ed682fe50e6712fcc1c080166aa3207ff7e68fd434356c700c91e8eacae163", + "https://esm.sh/v133/micromark-extension-gfm@3.0.0/denonext/micromark-extension-gfm.mjs": "20bc0f0e4b24d88533f6c18499e4d9caf8313f09b5e8b98fdca8e89797fb73d1", + "https://esm.sh/v133/micromark-factory-destination@2.0.0/denonext/micromark-factory-destination.mjs": "8848a7308f95d7d799dd71a6e8334bd5df8488ce627032b92c672e77286a8a1f", + "https://esm.sh/v133/micromark-factory-label@2.0.0/denonext/micromark-factory-label.mjs": "2cc9c012bfdc90c2133dda52a8a90f53308bae72e89adc3ad49a31845dcc9d28", + "https://esm.sh/v133/micromark-factory-space@2.0.0/denonext/micromark-factory-space.mjs": "3e65163ae07864bcbef0ef7e45cf8ae7b41370830abdb28d6ef68787ef09cb84", + "https://esm.sh/v133/micromark-factory-title@2.0.0/denonext/micromark-factory-title.mjs": "1a8c1544e3500e678469d8ca8b421c30d48b737307c1900427b352c15a55be8e", + "https://esm.sh/v133/micromark-factory-whitespace@2.0.0/denonext/micromark-factory-whitespace.mjs": "bfa939695de2ea5ed0022a44062669a0a43504bead74497c8777ec1547644d70", + "https://esm.sh/v133/micromark-util-character@2.0.1/denonext/micromark-util-character.mjs": "18b451d148e1ccc3a9b18e5c4061d44a0485e8ec65ad805d20b2950a51c7213b", + "https://esm.sh/v133/micromark-util-chunked@2.0.0/denonext/micromark-util-chunked.mjs": "531cf323ba53649fdc30cd39ebba54253dfd847a4b23f806058ecc6cf67bca69", + "https://esm.sh/v133/micromark-util-classify-character@2.0.0/denonext/micromark-util-classify-character.mjs": "0fec8f5d8cf2389ee5363caa45511a5bf60536cff4deb34561372251916d2940", + "https://esm.sh/v133/micromark-util-combine-extensions@2.0.0/denonext/micromark-util-combine-extensions.mjs": "6ed82f5ec63151bf43f04c95d9170e22daf0c9b62d43613355eb411473d284be", + "https://esm.sh/v133/micromark-util-decode-numeric-character-reference@2.0.0/denonext/micromark-util-decode-numeric-character-reference.mjs": "5b73fb26d75893046bd4c7a8294cd58f2c60453dd42e1d99d3a0670105686057", + "https://esm.sh/v133/micromark-util-decode-string@2.0.0/denonext/micromark-util-decode-string.mjs": "cabcc352e028556daaef2dcba4bc6809f1b7884ff672d8b71ea8e6b3d5e30b41", + "https://esm.sh/v133/micromark-util-encode@2.0.0/denonext/micromark-util-encode.mjs": "6077703d774b2fd968ef53977add5d5d1e39ca0db74c5ee4359c540a5febcf48", + "https://esm.sh/v133/micromark-util-html-tag-name@2.0.0/denonext/micromark-util-html-tag-name.mjs": "a32f6a4aa82498405a88103fd5b0b2e27a8c7f27dc506862f993bf1a1f1716b6", + "https://esm.sh/v133/micromark-util-normalize-identifier@2.0.0/denonext/micromark-util-normalize-identifier.mjs": "f5b933ea50544d63e505ef11b7f257b97c5056e06417bac15ec02d3f00174c0e", + "https://esm.sh/v133/micromark-util-resolve-all@2.0.0/denonext/micromark-util-resolve-all.mjs": "c11d87d63d808a26231323012295490931159830a66c00854693ec9279fa09fd", + "https://esm.sh/v133/micromark-util-sanitize-uri@2.0.0/denonext/micromark-util-sanitize-uri.mjs": "b0ecad8352fa160a7558cbeba6e38190afe621bdd9c9defffb362cd06eaf86fb", + "https://esm.sh/v133/micromark-util-subtokenize@2.0.0/denonext/micromark-util-subtokenize.mjs": "f64374f69c2c9b0836c3e26cfeaafe1da8dc6f947627872a6ae204a05e7f4eb5", + "https://esm.sh/v133/micromark@4.0.0/denonext/micromark.mjs": "f4474cc2e7ea71ebd62e80da60c70cefcf280812b0a236eebc9623dd82da8c2b", + "https://esm.sh/v133/minimatch@5.1.6/denonext/minimatch.mjs": "ef48e48dbf83da156ca3f864f52b5edc2b075fe03013dd2f85ee56d27796b12d", + "https://esm.sh/v133/nanoid@3.3.6/denonext/non-secure.js": "9339526d48828770370eba72bf719a40b74de0e1197d8a582c15fbd9ab837d3d", + "https://esm.sh/v133/node_fetch.js": "b11355358cf61343a3c30bd5942df60a3586d13e2c979b515164bfe851662798", + "https://esm.sh/v133/nth-check@2.1.1/denonext/nth-check.mjs": "09756f685b13060eedd8eeab884306ee0c2d932ce2a91f9ba0bbed692123c21e", + "https://esm.sh/v133/once@1.4.0/denonext/once.mjs": "d7e21ef5a35414088302698d792bc53e29e3678087c03046155b676de4eeb2cc", + "https://esm.sh/v133/parse-srcset@1.0.2/denonext/parse-srcset.mjs": "79b825d12a392cca8b39a53f6189e4f6d8c81612bbed7e3bb7946195d5264737", + "https://esm.sh/v133/pathval@1.1.1/denonext/pathval.mjs": "d34809f4ad27fae1b72ca58c5961b077400f86c01ac0406373d74f2f7650ca83", + "https://esm.sh/v133/picocolors@1.0.0/denonext/picocolors.mjs": "5d53d745955947dce84f82222cfd00fd021239fcbe9336c6bbfe34ed2f40886c", + "https://esm.sh/v133/pluralize@8.0.0/denonext/pluralize.mjs": "c874f2612d057d4484c386f6a093496c73bbf249f764a1c34b2417cd952ac153", + "https://esm.sh/v133/postcss-selector-parser@6.0.13/denonext/postcss-selector-parser.mjs": "a2f13b017101cf58436235ad5cce3db0aa9ac50672d5a395d000bbe984c06f02", + "https://esm.sh/v133/postcss@8.4.31/denonext/postcss.mjs": "beef26461b34dfc8e4d6b9d6290f7645c6423e727f2984d7a14db695566d536f", + "https://esm.sh/v133/property-information@6.3.0/denonext/property-information.mjs": "892dd4dcba102c3cf2becccefb9bcc89b208a133e0547d8d8929016ffd4be0ca", + "https://esm.sh/v133/purgecss@5.0.0/denonext/purgecss.mjs": "e427c7c29addd68f8e356c9e9d1e8ed1f9c4368e346d54ebd520fbe166124b78", + "https://esm.sh/v133/rehype-stringify@10.0.0/denonext/rehype-stringify.mjs": "81b85698b91607a02c9cfa58273a488df63af8cd38585662cf5caab268c6b971", + "https://esm.sh/v133/remark-gfm@4.0.0/denonext/remark-gfm.mjs": "732286885639b09294dcb8c350c9ebc069e755b8ea43def5fbabc62836af8518", + "https://esm.sh/v133/remark-parse@11.0.0/denonext/remark-parse.mjs": "afca5375aa24e3af6f0a5f913f2ad8a4f234c4268962acfdf802f8859fc86e84", + "https://esm.sh/v133/remark-rehype@11.0.0/denonext/remark-rehype.mjs": "d446065098f7d1f1b1d94bc9745bfe5d0031b5ba73bd52aed82f516b3b093831", + "https://esm.sh/v133/robust-predicates@3.0.2/denonext/robust-predicates.mjs": "7c3fa8b827ecabd4916f3db9756fc246b113cb71ac17485d3dc220538f7d3aeb", + "https://esm.sh/v133/rss-parser@3.13.0/denonext/rss-parser.mjs": "cb21fda95b51634ce528e20e435724f4b8fbfc1e2679ff223e246a8c876c8100", + "https://esm.sh/v133/sanitize-html@2.11.0/denonext/sanitize-html.mjs": "a38e053640f39025c0abbfb9bed48c1c3d5e506bd1c29e517f20ea464d7a4f0e", + "https://esm.sh/v133/sax@1.3.0/denonext/sax.mjs": "9bc4584a87fa83f1ce8bd60ddda5864b3bc7895ef6f751a818d2aa0f7a426505", + "https://esm.sh/v133/source-map-js@1.0.2/denonext/lib/source-map-generator.js": "8aff2603c0b8e01a585cd0d43582e6bc2787071fd68c4eb7a4dfe03da9cab69e", + "https://esm.sh/v133/source-map-js@1.0.2/denonext/source-map-js.mjs": "81ed2d1f28bd08a762f6819d5b366285a1bacf4b5c3757d866c79e4950529bfd", + "https://esm.sh/v133/space-separated-tokens@2.0.2/denonext/space-separated-tokens.mjs": "f30773a9959cacfe7511c250e5d125a9f88ee00d3aef6e87b4d17fe49806b276", + "https://esm.sh/v133/string-argv@0.3.1/denonext/string-argv.mjs": "802b8689f42e9b7cbe24ad39262c305f2541ed227a0180afc108bd41bab1bb0e", + "https://esm.sh/v133/stringify-entities@4.0.3/denonext/stringify-entities.mjs": "5d15c986adb7d140e681f634a3380d420c4e825c0091eb3a71629a8a64d92225", + "https://esm.sh/v133/svgo@3.0.2/denonext/lib/types.js": "068645d5a1e6877130dadbba10c9479c497527ec2ba6f8d6301fd5135bae9e30", + "https://esm.sh/v133/svgo@3.0.2/denonext/plugins/plugins-types.js": "ddee53efd429f98e944d5a654d1fb5814b554d3923ca1addb639c3ed649ada98", + "https://esm.sh/v133/svgo@3.0.2/denonext/svgo.mjs": "98fecf19c79214e99477669ed5b08514449d38907ae40a4f4b021e254940570f", + "https://esm.sh/v133/svgo@3.0.2/lib/types": "1319db9791f5a3a2aa0708510033008189ee94a0c885c78c4235a5804897699e", + "https://esm.sh/v133/svgo@3.0.2/plugins/plugins-types": "97bf3a120a665ea4901adb67530d022214c84373e7fc9c558497ef59ce2b6306", + "https://esm.sh/v133/trim-lines@3.0.1/denonext/trim-lines.mjs": "f01a20253341eb2554f307ab05bc9cd93d6f33bcbb24fde2fc9fcd857564283e", + "https://esm.sh/v133/trough@2.1.0/denonext/trough.mjs": "d7c1b66bf8739a28bcc6bbf17919f6b54b7b25cceabfb0e694d192232c83f1fd", + "https://esm.sh/v133/tunnel@0.0.6/denonext/tunnel.mjs": "0a5bc75c6c9ebb994955935269d2462da6de3f5238df9e21e396ba21599ec6dc", + "https://esm.sh/v133/type-detect@4.0.8/denonext/type-detect.mjs": "deb58bd7203992249a5795f7da35d00b67077fe6c03019349cb614ba22ef52ad", + "https://esm.sh/v133/uhyphen@0.2.0/denonext/uhyphen.mjs": "53c707ad31488ef2f131e2ac9257d91b7b590a777e60288a34b3a04f1e65ad5a", + "https://esm.sh/v133/undici@5.25.4/denonext/undici.mjs": "55a7d1abbf4f84ae9918e5bb734a3e0800128dcf699aafe482a3b93690f765f8", + "https://esm.sh/v133/undici@5.26.5/denonext/undici.mjs": "2a9406e55bed9a969b599013c62158cca85bc9270ac942d4746430dfb9a5ecb7", + "https://esm.sh/v133/unified@11.0.4/denonext/unified.mjs": "2323a390ff208a21373d87282abdd08771c21dbaa7e005f466177176fb21838b", + "https://esm.sh/v133/unist-util-is@6.0.0/denonext/unist-util-is.mjs": "d92da46b3a1084450f150cc06c02f3bf85b93ab4c4b43a960d72a4f5678e95cc", + "https://esm.sh/v133/unist-util-position@5.0.0/denonext/unist-util-position.mjs": "aaef05774a54f2b84400e98325219e2ba6cf966e318173bcd895647c56bb8871", + "https://esm.sh/v133/unist-util-stringify-position@4.0.0/denonext/unist-util-stringify-position.mjs": "dabd32cb2b590bbb077fc6f6591a2e065cffd6c55646ba383455926a27ea64d7", + "https://esm.sh/v133/unist-util-visit-parents@6.0.1/denonext/do-not-use-color.js": "a1c0a6b93471dd4ed996804dd8a2b9f753c83c4a2da98373253e6b312c8492e2", + "https://esm.sh/v133/unist-util-visit-parents@6.0.1/denonext/unist-util-visit-parents.mjs": "8e84aa1085070d1172c01923417149f263d0a347d90d6cdb9ebd7da7a6bb228f", + "https://esm.sh/v133/unist-util-visit@5.0.0/denonext/unist-util-visit.mjs": "60c36744452e82ca036ec9469dcdafa84aaeb8f229870895a849d867ff53be3c", + "https://esm.sh/v133/universal-github-app-jwt@1.1.1/denonext/universal-github-app-jwt.mjs": "546280ef9d50e167172bc039c0c2f4348a728ae1732f467af726b08491d2ea18", + "https://esm.sh/v133/universal-user-agent@6.0.0/denonext/universal-user-agent.mjs": "2969647abd054007e6d838dd2ecc7de9c513c18c43322dfa3de57eec8d2d6447", + "https://esm.sh/v133/util-deprecate@1.0.2/denonext/util-deprecate.mjs": "f69f67cf655c38428b0934e0f7c865c055834a87cc3866b629d6b2beb21005e9", + "https://esm.sh/v133/uuid@8.3.2/denonext/uuid.mjs": "c0da38266b24ac79d62b02290f17caba7e75d078f6771bbe8fb61bdef60f837c", + "https://esm.sh/v133/vfile-message@4.0.2/denonext/vfile-message.mjs": "b97cf857a7b37564a88941434785394016d17620d2021b67695bb4ee696a0c77", + "https://esm.sh/v133/vfile@6.0.1/denonext/do-not-use-conditional-minpath.js": "9a7ca0443aa0dff4d6a74822ba54cd1a5077a75d2f740d4d99cd8897a7f62a3c", + "https://esm.sh/v133/vfile@6.0.1/denonext/do-not-use-conditional-minproc.js": "34e683b50c7d1e17a90a522afafcdb032ceaeff2e0eb5dbca28f9b4783de73ba", + "https://esm.sh/v133/vfile@6.0.1/denonext/do-not-use-conditional-minurl.js": "79e90ed8915870371874d7ca17f8cbb3a8cab5b4e054bbcff85f7c91df32d0ba", + "https://esm.sh/v133/vfile@6.0.1/denonext/vfile.mjs": "9cf63dac100c07bca8daa46a7abbf0cfb58a8aa8cea68bb09cc020a26b4cefd4", + "https://esm.sh/v133/wrappy@1.0.2/denonext/wrappy.mjs": "3c31e4782e0307cf56b319fcec6110f925dafe6cb47a8fa23350d480f5fa8b06", + "https://esm.sh/v133/xml-formatter@3.5.0/denonext/xml-formatter.mjs": "b520b98fd2b0e5776aba28bda64d73b24c4fae57d86e0d2ec05ed2ef10caa078", + "https://esm.sh/v133/xml-parser-xo@4.1.1/denonext/xml-parser-xo.mjs": "ef4e487ed0f58fe907adde2c83b43b5c1e2b359f62fda6dd653a30566b30773c", + "https://esm.sh/v133/xml2js@0.5.0/denonext/xml2js.mjs": "99a9fee90697c9a8257973c684cb481d477ce8a1a4801771dc5c2486177ec050", + "https://esm.sh/v133/xmlbuilder@11.0.1/denonext/xmlbuilder.mjs": "0237a65baf14b6bb98e2b6fa513dd259d7afa5bfbf4275cb0f6fa4c4ceee0d68", + "https://esm.sh/v133/zod-to-json-schema@3.21.4/denonext/zod-to-json-schema.mjs": "352b47c0b2f1bdc08a40409d14eb07d163e5f0d007f76d206aa4b0c42d11ec4f", + "https://esm.sh/v133/zod-validation-error@1.5.0/denonext/zod-validation-error.mjs": "5668026e1c5e48df41677eb1c0bf1b7c66e0fd125c40934f37d32bf72610543a", + "https://esm.sh/v133/zod@3.22.4/denonext/zod.mjs": "660128af5d1e921745c4d452472d103d9f2fc5afa508bbf233b83d35a272ad67", + "https://esm.sh/v133/zwitch@2.0.4/denonext/zwitch.mjs": "c0e8c246a1f38b425335ea78cc366a7801d3ef89701229a35f85a51310e6e49f", + "https://esm.sh/xml-formatter@3.5.0?pin=v133": "33e116a3e781d2721d23cb35a11f1e0a8b80ab33bcddb1035f66011c372230de", + "https://esm.sh/zod-to-json-schema@3.21.4?pin=v133": "ef0b327dd18d55ff42dda772783288b74a691f824c9659bc83a839b90552732b", + "https://esm.sh/zod-validation-error@1.5.0?pin=v133": "78c876230174f315432e87ae4c761c2a833f2abddf0544a335ff5c80fdb42ddb", + "https://unpkg.com/emoji.json@15.1.0/emoji.json": "efe5784e4997a0c8f651666f965cc766927fe5d39a7cb4d38348f6f7ac9305d9" + } +} diff --git a/docs/contributing/requests/01_graphql.md b/docs/contributing/requests/01_graphql.md new file mode 100644 index 00000000000..258787cf424 --- /dev/null +++ b/docs/contributing/requests/01_graphql.md @@ -0,0 +1,73 @@ +## 📧 Using GitHub GraphQL API + +The preferred way to interact with GitHub is using the GraphQL API, which is usually more powerful and flexible than the REST API. + +GraphQL API requests are abstracted by `Requests.graphql()` method from [`@engine/components/requests.ts`](/source/engine/components/requests.ts). + +In plugins, requests can be performed through the `Plugin.graphql()` method. In processors, requests can be performed through the `Processor.requests.graphql()` method only if `Processor.requesting` +is set to `true`. + +**Useful resources** + +- [GraphQL introduction](https://graphql.org/learn) +- [GitHub GraphQL API documentation](https://docs.github.com/en/graphql) +- [GitHub GraphQL explorer](https://docs.github.com/en/graphql/overview/explorer) + +### 1ī¸âƒŖ Creating a GraphQL query + +Create a new `.graphql` file inside the `queries/` subdirectory. + +```graphql +# queries/example.graphql +query Example($login: String!) { + entity: user(login: $login) { + name + } +} +``` + +Fields should be renamed to keep consistency with the rest of the codebase, but also to ease the handling of data when a query is compatible with different entities. + +### 2ī¸âƒŖ Executing a GraphQL query + +Use the `graphql()` method to execute a GraphQL query. It accepts three arguments: + +1. The name of the query file _(without the extension)_ +2. An optional object with the variables to pass to the query +3. An optional object with the following properties: + +- `paginate`: Whether the query is paginated and should be executed until cursor reaches the end + +```ts +// mod.ts +const { entity: { name } } = await this.graphql("example", { login: handle }) +``` + +A paginated GraphQL query is handled by [@octokit/plugin-paginate-graphql](https://github.com/octokit/plugin-paginate-graphql.js) and must contain the following fields: + +```graphql +pageInfo { + hasNextPage + endCursor +} +``` + +### 3ī¸âƒŖ Creating a mocked GraphQL response for testing + +Create a new `.graphql.ts` file inside the `tests/` subdirectory _(file name must match the query file name)_. + +```ts +// tests/example.graphql.ts +import { faker, is, mock } from "@engine/utils/testing.ts" + +export default mock({ login: is.string() }, ({ login }) => ({ + entity: { + name: `${login} ${faker.person.lastName()}`, + }, +})) +``` + +The `mock()` function accepts two arguments: + +1. The schema of the response, described using [Zod](https://zod.dev) _(it should match the GraphQL variables description)_ +2. A callback function that receives the parsed variables and returns a mocked response diff --git a/docs/contributing/requests/02_rest.md b/docs/contributing/requests/02_rest.md new file mode 100644 index 00000000000..20a39dcc643 --- /dev/null +++ b/docs/contributing/requests/02_rest.md @@ -0,0 +1,49 @@ +## ✉ī¸ Using GitHub REST API + +REST API requests are abstracted by `Requests.rest()` method from [`@engine/components/requests.ts`](/source/engine/components/requests.ts). + +In plugins, requests can be performed through the `Plugin.rest()` method. In processors, requests can be performed through the `Processor.requests.rest()` method only if `Processor.requesting` is set +to `true`. + +**Useful resources** + +- [GitHub REST API documentation](https://docs.github.com/en/rest) +- [Octokit documentation](https://octokit.github.io/rest.js/v20) + +### 1ī¸âƒŖ Executing a REST query + +Use the `rest()` method to execute a REST query. It accepts three arguments: + +1. The octokit REST method to query +2. An optional object with the variables to pass to the query +3. An optional object with the following properties: + +- `paginate`: Whether the query is paginated and should be executed until cursor reaches the end + +```ts +// mod.ts +const zen = this.rest(this.api.meta.getZen) +``` + +### 2ī¸âƒŖ Creating a mocked REST response for testing + +Create a single `rest.ts` file inside the `tests/` subdirectory. + +```ts +// tests/rest.ts +import { mock, Status } from "@engine/utils/testing.ts" + +export default { + "/zen": mock({}, () => ({ + status: Status.OK, + data: new TextEncoder().encode("Anything added dilutes everything else."), + })), +} +``` + +A single default object should be exported, mapping REST endpoints to mocked responses. + +The `mock()` function accepts two arguments: + +1. The schema of the response, described using [Zod](https://zod.dev) _(it should match the REST variables description)_ +2. A callback function that receives the parsed variables and returns a mocked response including both HTTP status and data. diff --git a/docs/contributing/requests/03_fetch.md b/docs/contributing/requests/03_fetch.md new file mode 100644 index 00000000000..72c661485c1 --- /dev/null +++ b/docs/contributing/requests/03_fetch.md @@ -0,0 +1,41 @@ +## 📩 Using `fetch()` + +Fetching resources is abstracted by `Requests.fetch()` method from [`@engine/components/requests.ts`](/source/engine/components/requests.ts), which is using the native +[`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) function under the hood. + +In plugins, requests can be performed through the `Plugin.fetch()` method. In processors, requests can be performed through the `Processor.requests.fetch()` method only if `Processor.requesting` is +set to `true`. + +Navigation is subject to [Deno permissions](https://docs.deno.com/runtime/manual/basics/permissions) which means that resources cannot be accessed without associated grants. + +**Useful resources** + +- [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Learn) +- [`fetch()` documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) + +### 1ī¸âƒŖ Execute an HTTP query + +Use the `fetch()` method to perform an HTTP query. It accepts two arguments: + +1. The URL to query +2. An optional object with the following properties: + +- `type`: A conversion to apply (either `"json"`, `"text"`, or `"response"` to receive the raw response object) +- `options`: The options to pass to the native [`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) function + +```ts +const { foo } = await this.fetch("https://metrics.test/example", { type: "json", options: { method: "GET" } }) +``` + +### 2ī¸âƒŖ Create a mocked HTTP response for testing + +Create a new `.http.ts` file inside the `tests/` subdirectory _(file name must match the url pathname)_. Any HTTP request to the `.test` TLD will be intercepted and mocked using the associated file. + +```ts +// example.http.ts +import { faker, mock } from "@engine/utils/testing.ts" + +export default mock({}, () => { + return { foo: faker.lorem.word() } +}) +``` diff --git a/docs/contributing/requests/04_webscraping.md b/docs/contributing/requests/04_webscraping.md new file mode 100644 index 00000000000..51dea07f1ab --- /dev/null +++ b/docs/contributing/requests/04_webscraping.md @@ -0,0 +1,57 @@ +## 🕸ī¸ Using web scraping + +Data may be harvested using web scraping when no API is available or for advanced use cases. Note that this method is often slower and less reliable, as it requires spawning a browser instance and +relying on unstable DOM structures. + +It can also be used to perform content manipulation, which can be useful in processors. + +Web scraping is abstracted by `Browser` from [`@engine/utils/browser.ts`](/source/engine/utils/browser.ts), which is using [Astral](https://github.com/lino-levan/astral) library under the hood. + +Navigation is subject to [Deno permissions](https://docs.deno.com/runtime/manual/basics/permissions) which means that resources cannot be accessed without associated grants. + +**Useful resources** + +- [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Learn) +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol) +- [Astral documentation](https://astral.deno.dev) + +### 1ī¸âƒŖ Create a DOM-enabled function + +Create a new `.ts` file inside the `dom/` subdirectory with a single exported default function. + +```ts +// dom/example.ts +/// +/** Example function */ +export default function (foo: string) { + return { foo, main: document.querySelector("main")?.innerHTML } +} +``` + +### 2ī¸âƒŖ Execute the DOM function + +Use `Browser.page()` method to spawn a new browser page. Enclose the code in a `try`/`finally` block to ensure the page is closed after execution to avoid memory leaks. + +```ts +// mod.ts +const page = await Browser.page({ log: this.log }) +try { + // ... +} finally { + await page.close() +} +``` + +When using "local content", use `Format.html()` method from [`@engine/utils/format.ts`](/source/engine/utils/format.ts) to wrap the HTML content in a valid document inside a `
` tag. + +```ts +// mod.ts +await page.setContent(Format.html("Example")) +``` + +To evaluate a DOM function, use `Page.evaluate()` method using the `dom://` protocol. + +```ts +// mod.ts +const { foo, main } = await page.evaluate("dom://example.ts", { args: ["foo"] }) +``` diff --git a/source/engine/components/component.ts b/source/engine/components/component.ts new file mode 100644 index 00000000000..1c7ed8bda22 --- /dev/null +++ b/source/engine/components/component.ts @@ -0,0 +1,194 @@ +// Imports +import type { component as schema, config } from "@engine/config.ts" +import { delay } from "std/async/delay.ts" +import type { Validator } from "@engine/utils/validation.ts" +import { Internal, is, parse, toSchema } from "@engine/components/internal.ts" +import { toFileUrl } from "std/path/to_file_url.ts" +import { MetricsError, throws } from "@engine/utils/errors.ts" +import { exists } from "std/fs/exists.ts" +import * as YAML from "std/yaml/parse.ts" +import { list, read } from "@engine/utils/deno/io.ts" +import * as dir from "@engine/paths.ts" +import { extract, test as hasfm } from "std/front_matter/yaml.ts" + +/** Component */ +export abstract class Component extends Internal { + /** Import meta */ + protected static readonly meta = import.meta + + /** Context */ + declare protected readonly context: is.infer + + /** Name */ + abstract readonly name: string + + /** Category */ + abstract readonly category: string + + /** Description */ + abstract readonly description: string + + /** Inputs */ + abstract readonly inputs: Validator + + /** Ouputs */ + abstract readonly outputs: Validator + + /** Scopes */ + readonly scopes = [] as string[] + + /** Supports */ + readonly supports = [] as string[] + + /** Permissions */ + readonly permissions = [] as string[] + + /** Icon */ + get icon() { + return this.name.match(/([\p{Emoji}\u200d]+)/gu)?.[0] ?? "⏚ī¸" + } + + /** Action */ + protected abstract action(state: state): Promise + + /** Is supported ? */ + protected abstract supported(state: state): Promise | void + + /** Run component */ + protected async run(state: state) { + this.log.info("execution started") + await parse(Component.state, state) + this.log.trace(this.context) + let result = undefined, error = null, recoverable = true + for (let attempt = 1; attempt <= (this.context.retries?.attempts ?? 1); attempt++) { + if (attempt > 1) { + this.log.message(`attempt ${attempt} of ${this.context.retries.attempts}`) + } + try { + // Run component action + await this.supported(state) + result = await parse(this.outputs, await this.action(state) ?? {}) + } catch (caught) { + error = caught + // Handle unrecoverable errors + if ((error instanceof MetricsError) && (error[MetricsError.unrecoverable as keyof MetricsError])) { + recoverable = false + } + } + // Handle general errors + if (error) { + this.log.warn(`attempt ${attempt} failed`) + this.log.warn(error) + // Retry on recoverable errors + if (recoverable && (this.context.retries.delay) && (attempt < this.context.retries.attempts)) { + this.log.warn(`next attempt in ${this.context.retries.delay}s`) + await delay(this.context.retries.delay * 1000) + continue + } + // Handle fatal errors + if (!recoverable) { + this.log.error(`error is not recoverable`) + } + if (this.context.fatal) { + this.log.error("execution failed") + this.log.error(error) + throw error + } + this.log.warn("execution failed") + } + this.log.success("execution completed") + break + } + return { result, error } + } + + /** List tests */ + async tests() { + const path = `${(this.constructor as typeof Component).path}/${this.id}/tests/list.yml` + if (!await exists(path)) { + return [] + } + const content = await read(path) + return YAML.parse(content) as Array & { name: string }> + } + + /** List documentations */ + docs() { + const path = `${(this.constructor as typeof Component).path}/${this.id}/docs` + return list(`${path}/*.md`, { sync: true }).map((file) => { + const raw = read(`${path}/${file}`, { sync: true }) + if (hasfm(raw)) { + const { attrs: { title = file, type }, body: content } = extract(raw) + return { title, content, ...(type ? { type } : {}) } + } + return { title: file, content: raw } + }) + } + + /** Load component statically */ + static async load(context: Record & { id: string }) { + let error = null + const url = /^(https?|file):/.test(context.id) + ? new URL(context.id) + : context.id.startsWith("metrics://") + ? toFileUrl(context.id.replace("metrics:/", dir.source)) + : toFileUrl(`${this.path}/${context.id}${context.id.endsWith("_test.ts") ? "" : "/mod.ts"}`) + const { default: Module } = await import(url.href).catch((reason) => (error = reason, {})) + if (!Module) { + throws(`${this.name} ${context.id} could not be loaded (${error})`) + } + return new Module(context) as Component + } + + /** Run component statically */ + static async run({ state, context }: { state: state; context: Record & { id: string } }) { + const component = await this.load(context) + return component.run(state) + } + + /** Result */ + static readonly result = is.object({ + source: is.object({ + id: is.string().nullable(), + index: is.number(), + }), + content: is.string(), + mime: is.string(), + base64: is.boolean(), + result: is.union([is.record(is.unknown()), is.instanceof(Error)]), + }) + + /** State */ + static readonly state = is.object({ + result: Component.result.optional(), + results: is.array(Component.result), + errors: is.array(is.object({ + source: is.string(), + message: is.string(), + severity: is.enum(["error", "warning"]), + })), + }) + + /** Metadata */ + get metadata() { + return { + id: this.id, + icon: this.icon, + name: this.name, + category: this.category, + description: this.description, + scopes: this.scopes, + supports: this.supports, + permissions: this.permissions, + inputs: toSchema(this.inputs), + outputs: toSchema(this.outputs), + docs: this.docs(), + } + } +} + +/** State */ +export type state = is.infer + +// Exports +export { is, parse, toSchema } diff --git a/source/engine/components/component_test.ts b/source/engine/components/component_test.ts new file mode 100644 index 00000000000..1d6e4fab8ee --- /dev/null +++ b/source/engine/components/component_test.ts @@ -0,0 +1,80 @@ +import { dir, expect, is, MetricsError, t, throws } from "@engine/utils/testing.ts" +import { Component } from "@engine/components/component.ts" +import { parse } from "@engine/utils/validation.ts" + +export default class TestComponent extends Component { + static readonly meta = import.meta + readonly name = "Test plugin" + readonly category = "testing" + readonly description = "Test plugin" + readonly inputs = is.object({ error: is.boolean().default(false) }) + readonly outputs = is.object({ ok: is.boolean() }) + attempts = 0 + // deno-lint-ignore require-await + protected async action() { + this.attempts++ + if ((this.context.args.error) && (this.attempts < 2)) { + throws("Expected error", { unrecoverable: !!this.context.args.unrecoverable }) + } + return { ok: true } + } + protected async supported() {} + static get path() { + return `${dir.source}/engine/components` + } + constructor(args: Record = {}) { + super({ logs: "none" }) + this.context.args = args + this.context.retries = { attempts: args.retry ? 3 : 1, delay: 1 } + this.context.fatal = args.fatal as boolean ?? false + } + async run() { + const state = await parse(Component.state, { results: [], errors: [] }) + return super.run(state) + } +} + +Deno.test(t(import.meta, "`.icon` returns an emoji"), { permissions: "none" }, async () => { + const component = await Component.load({ id: import.meta.url }) + expect(component.icon).to.equal("⏚ī¸") +}) + +Deno.test(t(import.meta, "`.tests()` returns a list of tests"), { permissions: { read: [dir.source] } }, async () => { + const component = await Component.load({ id: import.meta.url }) + await expect(component.tests()).to.be.eventually.be.an("array") +}) + +Deno.test(t(import.meta, "`.tests()` returns an empty array if no tests are defined"), { permissions: { read: [dir.source] } }, async () => { + const component = await Component.load({ id: import.meta.url }) + Object.assign(component, { id: "__test__" }) + await expect(component.tests()).to.be.eventually.be.an("array") +}) + +Deno.test(t(import.meta, "`.run()` is able to return results"), { permissions: "none" }, async () => { + const component = new TestComponent() + await expect(component.run()).to.eventually.deep.equal({ result: { ok: true }, error: null }) +}) + +Deno.test(t(import.meta, "`.run()` is able to return errors "), { permissions: "none" }, async () => { + const component = new TestComponent({ error: true }) + const status = await component.run() + await expect(status.result).to.be.undefined + await expect(status.error).to.be.instanceOf(MetricsError, /expected error/i) +}) + +Deno.test(t(import.meta, "`.run()` retries on errors"), { permissions: "none" }, async () => { + const component = new TestComponent({ error: true, retry: true }) + await expect(component.run()).to.be.eventually.be.ok.and.to.containSubset({ result: { ok: true } }) + await expect(component.attempts).to.equal(3) +}) + +Deno.test(t(import.meta, "`.run()` does not retry on unrecoverable errors"), { permissions: "none" }, async () => { + const component = new TestComponent({ error: true, retry: true, unrecoverable: true }) + await expect(component.run()).to.be.eventually.be.ok.and.to.containSubset({ result: undefined }) + await expect(component.attempts).to.equal(1) +}) + +Deno.test(t(import.meta, "`.run()` throws on errors with `fatal:true`"), { permissions: "none" }, async () => { + const component = new TestComponent({ error: true, fatal: true }) + await expect(component.run()).to.be.rejectedWith(MetricsError, /expected error/i) +}) diff --git a/source/engine/components/internal.ts b/source/engine/components/internal.ts new file mode 100644 index 00000000000..e544cf37ad4 --- /dev/null +++ b/source/engine/components/internal.ts @@ -0,0 +1,48 @@ +// Imports +import { Logger } from "@engine/utils/log.ts" +import type { internal as schema } from "@engine/config.ts" +import { is, parse, toSchema } from "@engine/utils/validation.ts" +import { toFileUrl } from "std/path/to_file_url.ts" +import * as dir from "@engine/paths.ts" + +/** Internal component */ +export abstract class Internal { + /** Import meta */ + protected static readonly meta = import.meta + + /** Import meta */ + protected readonly meta + + /** Logger */ + protected readonly log + + /** Context */ + protected readonly context + + /** Identifier */ + readonly id + + /** Constructor */ + protected constructor(context: is.infer) { + const constructor = this.constructor as typeof Internal + const tags = {} as Record + this.meta = constructor.meta + this.id = this.meta.url.replace(toFileUrl(constructor.path).href, "").replace(/(?:(?:_test)|(?:\/mod))\.ts$/, "").replace(/^\//, "") + this.context = context ?? {} + if (Internal.tracker in this.context) { + tags[Internal.tracker] = this.context[Internal.tracker] + } + this.log = new Logger(this.meta, { level: this.context.logs, tags }) + } + + /** Component root path */ + protected static get path() { + return dir.source + } + + /** Internal tracker symbol */ + protected static readonly tracker = Symbol.for("@@tracker") +} + +// Exports +export { is, parse, toSchema } diff --git a/source/engine/components/internal_test.ts b/source/engine/components/internal_test.ts new file mode 100644 index 00000000000..a02c299caf9 --- /dev/null +++ b/source/engine/components/internal_test.ts @@ -0,0 +1,14 @@ +import { Internal } from "@engine/components/internal.ts" +import { expect, t, test } from "@engine/utils/testing.ts" + +class InternalTest extends Internal { + protected static readonly meta = import.meta + constructor() { + super(null as test) + } +} + +Deno.test(t(import.meta, "is instantiable once extended"), { permissions: "none" }, () => { + const internal = new InternalTest() + expect(internal.id).to.equal("engine/components/internal") +}) diff --git a/source/engine/components/plugin.ts b/source/engine/components/plugin.ts new file mode 100644 index 00000000000..bfca52359d6 --- /dev/null +++ b/source/engine/components/plugin.ts @@ -0,0 +1,159 @@ +// Imports +import { Component, is, parse, state } from "@engine/components/component.ts" +import { list, read } from "@engine/utils/deno/io.ts" +import { plugin as schema, plugin_nameless } from "@engine/config.ts" +import * as ejs from "y/ejs@3.1.9?pin=v133" +import { Requests } from "@engine/components/requests.ts" +import { Formatter } from "@engine/utils/format.ts" +import { basename } from "std/path/basename.ts" +import { Processor } from "@engine/components/processor.ts" +import { throws } from "@engine/utils/errors.ts" +import { RequestInterface } from "y/@octokit/types@11.1.0?pin=v133" + +/** Plugin */ +export abstract class Plugin extends Component { + /** Import meta */ + protected static readonly meta = import.meta + + /** Context */ + declare protected readonly context: is.infer + + /** Constructor */ + protected constructor(context: Plugin["context"]) { + super(context) + this.requests = new Requests(this.meta, this.context) + for (const log of [this, this.requests] as unknown as Array<{ log: Component["log"] }>) { + Object.assign(log, { log: this.log.with({ handle: this.context.handle, entity: this.context.entity }) }) + } + this.log.trace("instantiated") + } + + /** Requests */ + protected readonly requests + + /** REST api (Note: always use `this.rest()` to perform queries in order for queries to be properly traced and mocked) */ + protected get api() { + return this.requests.api + } + + /** Perform a REST query */ + protected rest(endpoint: T, vars = {} as Parameters[0], options = {} as Parameters[2]) { + return this.requests.rest(endpoint, vars, options) + } + + /** Perform a GraphQL query */ + protected graphql(...args: Parameters) { + return this.requests.graphql(...args) + } + + /** Perform an HTTP query */ + protected fetch(...args: Parameters) { + return this.requests.fetch(...args) + } + + /** EJS template additional rendering context */ + protected _renderctx = {} as Record + + /** Render an EJS template */ + protected async render({ state }: { state: state }) { + const name = this.context.template! + const path = name.startsWith("metrics://") ? new URL(name) : new URL(`templates/${name}.ejs`, this.meta.url) + const template = await read(path) + const { data: args = {} } = await this.inputs.safeParseAsync(this.context.args) as { data?: Record } + this.log.debug(`rendering template: ${name}`) + return ejs.render( + template, + Object.assign(structuredClone(this.context), { + args, + result: state.result?.result ?? null, + state, + format: new Formatter(this.context), + ...this._renderctx, + }), + { + async: true, + _with: true, + context: null, + }, + ) + } + + /** Is supported ? */ + protected supported() { + if ((this.supports.length) && (!this.supports.includes(this.context.entity))) { + throws(`${this.id} not supported for ${this.context.entity}`, { unrecoverable: true }) + } + } + + /** Run component */ + protected async run(state: state) { + const { result: raw, error } = await super.run(state) + const result = { source: { id: this.id, index: 0 }, content: "", mime: "application/xml", base64: false, result: raw ?? error } + state = { result, ...state } + state.results.push(result) + + // Render content + if (this.context.template) { + result.content = await this.render({ state }) + } + + // Apply processors + this.context.processors ??= [] + for (const [i, processor] of Object.entries(this.context.processors)) { + this.log.debug(`running processor: ${processor.id}`) + const tracker = Component.tracker in this.context ? `${this.context[Component.tracker]}[${i}]` : processor.id + const processed = await Processor.run({ tracker, state, context: processor, plugin: this }) + if (processed.result) { + Object.assign(result.result, processed.result) + } + if (processed.error) { + state.result!.result = processed.error + state.errors.push({ severity: "error", source: tracker, message: `${processed.error}` }) + } + } + + return { result, error } + } + + /** List templates */ + async templates() { + return [...await list(`${(this.constructor as typeof Plugin).path}/${this.id}/templates/*.ejs`)].map((ejs) => basename(ejs).replace(/\.ejs$/, "")) + } + + /** List plugins */ + static async list() { + return [...await list(`${this.path}/**/mod.ts`)].map((mod) => mod.replace(/\/mod\.ts$/, "")) + } + + /** Run component statically */ + static async run({ tracker, state, context }: { tracker?: string; state?: state; context: Record }) { + state ??= await parse(this.state, { results: [], errors: [] }) + if (tracker) { + Object.defineProperties(context, { [Component.tracker]: { enumerable: false, value: tracker } }) + } + if (!context.id) { + context.id = Plugin.nameless + } + return await super.run({ state, context: context as typeof context & { id: string } }) as unknown as ReturnType + } + + /** Load component statically */ + static async load(context: Record & { id: string }) { + if (!context.id) { + context.id = Plugin.nameless + } + return await super.load(context) as Plugin + } + + /** Plugins root path */ + protected static get path() { + return `${super.path}/plugins` + } + + /** Plugin that can be used without explicit naming */ + static readonly nameless = plugin_nameless +} + +// Exports +export { is, parse } +export type { state } diff --git a/source/engine/components/plugin_test.ts b/source/engine/components/plugin_test.ts new file mode 100644 index 00000000000..ae39ae52d6e --- /dev/null +++ b/source/engine/components/plugin_test.ts @@ -0,0 +1,84 @@ +import { dir, expect, is, MetricsError, t, test } from "@engine/utils/testing.ts" +import { config, getPermissions, Plugin, setup } from "@engine/components/tests/context.ts" +import { deepMerge } from "std/collections/deep_merge.ts" +import { process } from "@engine/process.ts" + +export default class TestPlugin extends Plugin { + static readonly meta = import.meta + readonly name = "🧑đŸģ‍đŸ”Ŧ Test plugin" + readonly category = "testing" + readonly supports = ["user"] + readonly description = "Test plugin" + readonly inputs = is.object({}) + readonly outputs = is.object({}) + protected async action() {} + static get path() { + return `${dir.source}/engine/components` + } +} + +Deno.test(t(import.meta, "`.icon` returns an emoji"), { permissions: "none" }, async () => { + const plugin = await Plugin.load({ id: import.meta.url }) + expect(plugin.icon).to.equal("🧑đŸģ‍đŸ”Ŧ") +}) + +Deno.test(t(import.meta, "`.supported()` is a noop when supported"), { permissions: "none" }, async () => { + await expect(Plugin.run({ context: { id: import.meta.url, entity: "user", fatal: true } })).to.be.eventually.be.ok +}) + +Deno.test(t(import.meta, "`.supported()` throws when unsupported"), { permissions: "none" }, async () => { + await expect(Plugin.run({ context: { id: import.meta.url, entity: "organization", fatal: true } })).to.be.rejectedWith(MetricsError, /not supported for organization/i) +}) + +Deno.test(t(import.meta, "`.render()` renders template content"), { permissions: { read: [dir.source] } }, async () => { + await expect(Plugin.run({ context: { id: import.meta.url, entity: "user", template: "../tests/template", fatal: true } })).to.be.eventually.containSubset({ result: { content: "foo" } }) + await expect(Plugin.run({ context: { id: import.meta.url, entity: "user", template: "metrics://engine/components/tests/template.ejs", fatal: true } })).to.be.eventually.containSubset({ + result: { content: "foo" }, + }) +}) + +Deno.test(t(import.meta, "`.templates()` returns a list of available templates"), { permissions: { read: [dir.source] } }, async () => { + const plugin = await Plugin.load({ id: import.meta.url }) + await expect(plugin.templates()).to.be.eventually.be.an("array") +}) + +Deno.test(t(import.meta, "`static .load()` can instantiate from string id"), { permissions: "none" }, async () => { + await expect(TestPlugin.load({ id: "plugin_test.ts" })).to.eventually.be.instanceOf(TestPlugin) +}) + +Deno.test(t(import.meta, "`static .load()` can instantiate from url scheme"), { permissions: "none" }, async () => { + await expect(Plugin.load({ id: import.meta.url })).to.eventually.be.instanceOf(TestPlugin) +}) + +Deno.test(t(import.meta, "`static .load()` can instantiate from `metrics://` scheme"), { permissions: "none" }, async () => { + await expect(Plugin.load({ id: "metrics://engine/components/plugin_test.ts" })).to.eventually.be.instanceOf(TestPlugin) +}) + +Deno.test(t(import.meta, "`static .load()` without identifier returns the one defined by `Plugin.nameless`"), { permissions: "none" }, async () => { + await expect(Plugin.load({ logs: "none" } as test)).to.eventually.be.instanceOf(Plugin).and.to.have.property("id", Plugin.nameless) + await expect(Plugin.run({ context: { entity: "user" } })).to.be.fulfilled +}) + +Deno.test(t(import.meta, "`static .list()` returns a list of available plugins"), { permissions: { read: [dir.source] } }, async () => { + await expect(Plugin.list()).to.be.eventually.be.an("array") +}) + +for (const id of await Plugin.list()) { + const plugin = await Plugin.load({ id }) + const tests = await plugin.tests() + const templates = await plugin.templates() + const name = `${plugin.icon} plugins/${plugin.id}` + if (!tests?.length) { + Deno.test.ignore(t(name, null), () => void null) + continue + } + for (const test of tests) { + for (const template of templates) { + Deno.test(t(name, `*${template}* ${test.name}`), await getPermissions(test), async () => { + const { teardown } = setup(test) + await expect(process(deepMerge({ presets: { default: { plugins: { template } } } }, deepMerge(config, test, { arrays: "replace" })))).to.be.fulfilled.and.eventually.be.ok + teardown() + }) + } + } +} diff --git a/source/engine/components/processor.ts b/source/engine/components/processor.ts new file mode 100644 index 00000000000..ada20a9197a --- /dev/null +++ b/source/engine/components/processor.ts @@ -0,0 +1,94 @@ +// Imports +import { Component, is, parse, state } from "@engine/components/component.ts" +import type { processor as schema } from "@engine/config.ts" +import { list } from "@engine/utils/deno/io.ts" +import type { Plugin } from "@engine/components/plugin.ts" +import { Requests } from "@engine/components/requests.ts" +import { throws } from "@engine/utils/errors.ts" + +/** Processor */ +export abstract class Processor extends Component { + /** Import meta */ + protected static readonly meta = import.meta + + /** Context */ + declare protected readonly context: is.infer & { parent?: Record } + + /** Constructor */ + protected constructor(context: Processor["context"]) { + super(context) + if (this.context.parent) { + Object.assign(this, { log: this.log.with({ handle: this.context.parent.handle, entity: this.context.parent.entity }) }) + } + this.log.trace("instantiated") + } + + /** Inputs */ + readonly inputs = is.object({}) + + /** Outputs */ + readonly outputs = is.object({}) + + /** Is supported ? */ + protected async supported(state: state) { + const { mime } = await this.piped(state) + if ((this.supports.length) && (!this.supports.includes(mime))) { + throws(`${this.id} not supported for ${mime}`, { unrecoverable: true }) + } + } + + /** Retrieve piped result */ + protected async piped(state: state) { + await parse(Component.state.pick({ result: true }).required(), state) + return state.result! + } + + /** Requests */ + protected readonly requests = null as unknown as Requests + + /** Does this processor needs to perform requests ? */ + protected requesting = false + + /** List processors */ + static async list() { + return [...await list(`${this.path}/**/mod.ts`)].map((mod) => mod.replace(/\/mod\.ts$/, "")) + } + + /** Run component statically */ + static async run({ tracker, state, context, plugin }: { tracker?: string; state?: state; context: Record & { id: string }; plugin?: Plugin }) { + state ??= await parse(this.state, { result: { source: { id: null, index: 0 }, content: "", mime: "application/octet-stream", base64: false, result: {} }, results: [], errors: [] }) + if (tracker) { + Object.defineProperties(context, { [Component.tracker]: { enumerable: false, value: tracker } }) + } + if (plugin) { + Object.defineProperties(context, { + [Processor.plugin]: { enumerable: false, value: plugin }, + parent: { enumerable: false, value: (plugin as unknown as { context: Record }).context }, + }) + } + return super.run({ state, context: context as typeof context & { id: string } }) + } + + /** Load component statically */ + static async load(context: Record & { id: string }) { + const processor = await super.load(context) as Processor + if ((context[Processor.plugin]) && (processor.requesting)) { + const parent = context[Processor.plugin] as { requests: { octokit: unknown }; context: Requests["context"] } + const requests = Object.assign(new Requests(processor.meta, parent.context), { octokit: parent.requests.octokit }) + Object.assign(processor, { requests }) + } + return processor + } + + /** Plugin symbol */ + private static readonly plugin = Symbol.for("@@plugin") + + /** Processors root path */ + protected static get path() { + return `${super.path}/processors` + } +} + +// Exports +export { is, parse } +export type { state } diff --git a/source/engine/components/processor_test.ts b/source/engine/components/processor_test.ts new file mode 100644 index 00000000000..d3e9958330e --- /dev/null +++ b/source/engine/components/processor_test.ts @@ -0,0 +1,63 @@ +import { dir, expect, MetricsError, t } from "@engine/utils/testing.ts" +import { config, getPermissions, Processor, setup } from "@engine/components/tests/context.ts" +import { deepMerge } from "std/collections/deep_merge.ts" +import { process } from "@engine/process.ts" + +export default class TestProcessor extends Processor { + static readonly meta = import.meta + readonly name = "🧑đŸģ‍đŸ”Ŧ Test processor" + readonly category = "testing" + readonly supports = ["image/svg+xml"] + readonly description = "Test processor" + protected async action() {} + static get path() { + return `${dir.source}/engine/components` + } +} + +Deno.test(t(import.meta, "`.icon` returns an emoji"), { permissions: "none" }, async () => { + const plugin = await Processor.load({ id: import.meta.url }) + expect(plugin.icon).to.equal("🧑đŸģ‍đŸ”Ŧ") +}) + +Deno.test(t(import.meta, "`.supported()` is a noop when supported"), { permissions: "none" }, async () => { + const state = { result: { source: { id: null, index: 0 }, content: "", mime: "image/svg+xml", base64: false, result: {} }, results: [], errors: [] } + await expect(Processor.run({ state, context: { id: import.meta.url, fatal: true } })).to.be.eventually.be.ok +}) + +Deno.test(t(import.meta, "`.supported()` throws when unsupported"), { permissions: "none" }, async () => { + await expect(Processor.run({ context: { id: import.meta.url, fatal: true } })).to.be.rejectedWith(MetricsError, /not supported for application\/octet-stream/i) +}) + +Deno.test(t(import.meta, "`static .load()` can instantiate from string id"), { permissions: "none" }, async () => { + await expect(TestProcessor.load({ id: "processor_test.ts" })).to.eventually.be.instanceOf(TestProcessor) +}) + +Deno.test(t(import.meta, "`static .load()` can instantiate from url scheme"), { permissions: "none" }, async () => { + await expect(Processor.load({ id: import.meta.url })).to.eventually.be.instanceOf(TestProcessor) +}) + +Deno.test(t(import.meta, "`static .load()` can instantiate from `metrics://` scheme"), { permissions: "none" }, async () => { + await expect(Processor.load({ id: "metrics://engine/components/processor_test.ts" })).to.eventually.be.instanceOf(TestProcessor) +}) + +Deno.test(t(import.meta, "`static .list()` returns a list of available plugins"), { permissions: { read: [dir.source] } }, async () => { + await expect(Processor.list()).to.be.eventually.be.an("array") +}) + +for (const id of await Processor.list()) { + const processor = await Processor.load({ id }) + const tests = await processor.tests() + const name = `${processor.icon} processors/${processor.id}` + if (!tests?.length) { + Deno.test.ignore(t(name, null), () => void null) + continue + } + for (const test of tests) { + Deno.test(t(name, test.name), await getPermissions(test), async () => { + const { teardown } = setup(test) + await expect(process(deepMerge(config, test, { arrays: "replace" }))).to.be.fulfilled.and.eventually.be.ok + teardown() + }) + } +} diff --git a/source/engine/components/requests.ts b/source/engine/components/requests.ts new file mode 100644 index 00000000000..45d673e4826 --- /dev/null +++ b/source/engine/components/requests.ts @@ -0,0 +1,139 @@ +// Imports +import { Octokit } from "y/@octokit/rest@20.0.1?pin=v133" +import { paginateGraphql } from "y/@octokit/plugin-paginate-graphql@4.0.0?pin=v133" +import type { RequestInterface } from "y/@octokit/types@11.1.0?pin=v133" +import { Internal, is } from "@engine/components/internal.ts" +import { read } from "@engine/utils/deno/io.ts" +import type { requests as schema } from "@engine/config.ts" +import { version } from "@engine/version.ts" +import { throws } from "@engine/utils/errors.ts" + +/** Plugin */ +export class Requests extends Internal { + /** Import meta */ + protected static readonly meta = import.meta + + /** Context */ + declare protected readonly context: is.infer + + /** Constructor */ + constructor(meta: typeof Requests["meta"], context: Requests["context"]) { + super(context) + Object.assign(this, { meta }) + this.octokit = new (Octokit.plugin(paginateGraphql))({ + userAgent: `metrics/${version}`, + auth: this.context.token?.read(), + timeZone: this.context.timezone, + baseUrl: this.context.api, + }) + } + + /** Octokit SDK */ + private readonly octokit + + /** REST api (Note: always use `this.rest()` to perform queries in order for queries to be properly traced and mocked) */ + get api() { + return this.octokit.rest + } + + /** Perform a REST query */ + async rest(endpoint: T, vars = {} as Parameters[0], { paginate = false } = {}) { + const { endpoint: { DEFAULTS: { method, url } } } = endpoint + this.log.io(`calling rest query: ${method} ${url}`) + if (this.context.mock) { + let mock = null + try { + const { default: mod } = await import(new URL("tests/rest.ts", this.meta.url).href) + mock = mod + if ((typeof mock !== "object") || (!mock)) { + throws("Imported mock module is not a record of functions") + } + } catch (error) { + this.log.warn(`rest query ${method} ${url} could not be mocked`) + this.log.warn(error) + } + if (mock && (url! in mock)) { + this.log.debug(`mocking rest query: ${method} ${url}}`) + return mock[url!](vars) + } + } + if (paginate) { + return this.octokit.paginate(endpoint, vars) + } + return endpoint(vars) + } + + /** Perform a GraphQL query */ + async graphql(name: string, vars = {} as Record, { paginate = false } = {}) { + const path = new URL(`queries/${name}.graphql`, this.meta.url) + const query = await read(path).catch(() => "") + this.log.io(`calling graphql query: ${name}`) + if (this.context.mock) { + let mock = null + try { + const { default: mod } = await import(new URL(`tests/${name}.graphql.ts`, this.meta.url).href) + mock = mod + if (typeof mock !== "function") { + throws("Imported mock module is not a function") + } + } catch (error) { + this.log.warn(`graphql query ${name} could not be mocked`) + this.log.warn(error) + } + if (mock) { + this.log.debug(`mocking graphql query: ${name}`) + return mock(vars) + } + } + if (paginate) { + if (!query.includes("pageInfo")) { + throws(`Query is missing "pageInfo { hasNextPage, endCursor }" but pagination is enabled`) + } + return this.octokit.graphql.paginate(query, vars) + } + return this.octokit.graphql(query, vars) + } + + /** Perform an HTTP query */ + async fetch(url: string | URL, { type = "text" as string, options = {} as Parameters[1] } = {}) { + this.log.io(`calling http query: ${url}`) + if ((this.context.mock) && (new URL(url).host.endsWith(".test"))) { + let mock = null + try { + const name = new URL(url).pathname.slice(1) + const { default: mod } = await import(new URL(`tests/${name}.http.ts`, this.meta.url).href) + mock = mod + if (typeof mock !== "function") { + throws("Imported mock module is not a function") + } + } catch (error) { + this.log.warn(`http query ${url} could not be mocked`) + this.log.warn(error) + } + if (mock) { + this.log.debug(`mocking http query: ${name}`) + return mock(options) + } + } + const response = await fetch(url, options) + switch (type) { + case "json": + return response.json() + case "text": + return response.text() + default: + return response + } + } + + /** Ratelimit */ + async ratelimit() { + const { data: { resources: { core, search, graphql = { remaining: 0, limit: 0 } } } } = await this.rest(this.api.rateLimit.get) + this.log.io({ + core: `${core.remaining}/${core.limit}`, + graphql: `${graphql.remaining}/${graphql.limit}`, + search: `${search.remaining}/${search.limit}`, + }) + return { core: core.remaining, graphql: graphql.remaining, search: search.remaining } + } +} diff --git a/source/engine/components/requests_test.ts b/source/engine/components/requests_test.ts new file mode 100644 index 00000000000..204ab287d86 --- /dev/null +++ b/source/engine/components/requests_test.ts @@ -0,0 +1,72 @@ +import { Requests } from "@engine/components/requests.ts" +import { dir, expect, Status, t, test } from "@engine/utils/testing.ts" +import { Secret } from "@engine/utils/secret.ts" +import { dirname } from "std/path/dirname.ts" + +const options = { logs: "none", mock: true, api: "https://api.github.com", timezone: "Europe/Paris", token: new Secret(null) } as test +const requests = new Requests(import.meta, options) + +Deno.test(t(import.meta, "`.rest()` can perform REST API queries"), { permissions: "none" }, async () => { + await expect(requests.rest(requests.api.meta.root)).to.be.rejectedWith(Error, /requires net access/i) +}) + +Deno.test(t(import.meta, "`.rest()` handles paginated queries"), { permissions: "none" }, async () => { + await expect(requests.rest(requests.api.meta.root, {}, { paginate: true })).to.be.rejectedWith(Error, /requires net access/i) +}) + +Deno.test(t(import.meta, "`.rest()` can mock queries when present"), { permissions: { read: [dir.source] } }, async () => { + await expect(requests.rest(requests.api.meta.getOctocat)).to.be.eventually.containSubset({ status: Status.OK }).and.to.include.keys("data") +}) + +Deno.test(t(import.meta, "`.rest()` fallbacks on net when mock is invalid"), { permissions: "none" }, async () => { + const requests = new Requests({ url: `${dirname(import.meta.url)}/tests/rest.ts` } as test, options) + await expect(requests.rest(requests.api.meta.root)).to.be.rejectedWith(Error, /requires net access/i) +}) + +Deno.test(t(import.meta, "`.graphql()` can perform GraphQL API queries"), { permissions: "none" }, async () => { + await expect(requests.graphql("../tests/mock_missing", { foo: true })).to.be.rejectedWith(Error, /requires net access/i) +}) + +Deno.test(t(import.meta, "`.graphql()` handles paginated queries"), { permissions: { read: [dir.source] } }, async () => { + await expect(requests.graphql("../tests/mock_paginate", { foo: true }, { paginate: true })).to.be.rejectedWith(Error, /requires net access/i) +}) + +Deno.test(t(import.meta, "`.graphql()` throws on paginated queries without pagination in query"), { permissions: "none" }, async () => { + await expect(requests.graphql("../tests/mock_paginate_bad", { foo: true }, { paginate: true })).to.be.rejectedWith(Error, /missing "pageInfo.*" but pagination is enabled/i) +}) + +Deno.test(t(import.meta, "`.graphql()` can mock queries when present"), { permissions: { read: [dir.source] } }, async () => { + await expect(requests.graphql("../tests/mock", { foo: true })).to.be.eventually.deep.equal({ bar: true }) +}) + +Deno.test(t(import.meta, "`.graphql()` fallbacks on net when mock is invalid"), { permissions: { read: [dir.source] } }, async () => { + await expect(requests.graphql("../tests/mock_invalid", { foo: true })).to.be.rejectedWith(Error, /requires net access/i) +}) + +Deno.test(t(import.meta, "`.fetch()` returns plain text when asked"), { permissions: "none" }, async () => { + await expect(requests.fetch(`data:text/plain;base64,${btoa("foo")}`, { type: "text" })).to.eventually.equal("foo") +}) + +Deno.test(t(import.meta, "`.fetch()` returns parsed json when asked"), { permissions: "none" }, async () => { + await expect(requests.fetch(`data:application/json;base64,${btoa('{"foo":true}')}`, { type: "json" })).to.eventually.deep.equal({ foo: true }) +}) + +Deno.test(t(import.meta, "`.fetch()` returns response when asked"), { permissions: "none" }, async () => { + const fetched = requests.fetch(`data:text/plain;base64,${btoa("foo")}`, { type: "response" }) + await expect(fetched).to.eventually.be.instanceof(Response) + const response = await fetched + response.body?.cancel() +}) + +Deno.test(t(import.meta, "`.fetch()` can mock queries on `*.test` domain when present"), { permissions: { read: [dir.source] } }, async () => { + await expect(requests.fetch("https://metrics.test/mock")).to.be.eventually.equal("foo") + await expect(requests.fetch("https://metrics.test/mock_missing")).to.be.rejectedWith(Error, /requires net access/i) +}) + +Deno.test(t(import.meta, "`.fetch()` fallbacks on net when mock is invalid"), { permissions: { read: [dir.source] } }, async () => { + await expect(requests.fetch("https://metrics.test/mock_invalid")).to.be.rejectedWith(Error, /requires net access/i) +}) + +Deno.test(t(import.meta, "`.ratelimit()` returns remaining requests"), { permissions: "none" }, async () => { + await expect(requests.ratelimit()).to.eventually.include.keys("core", "graphql", "search") +}) diff --git a/source/engine/components/tests/context.ts b/source/engine/components/tests/context.ts new file mode 100644 index 00000000000..a371c285f1d --- /dev/null +++ b/source/engine/components/tests/context.ts @@ -0,0 +1,104 @@ +//Imports +import { dir, test } from "@engine/utils/testing.ts" +import { deepMerge } from "std/collections/deep_merge.ts" +import { Browser } from "@engine/utils/browser.ts" +import { Logger } from "@engine/utils/log.ts" +import { Component } from "@engine/components/component.ts" +import { Plugin } from "@engine/components/plugin.ts" +import { Processor } from "@engine/components/processor.ts" +import { sugar } from "@engine/config.ts" +import { env } from "@engine/utils/deno/env.ts" + +/** Default config for components testing */ +export const config = { + presets: { + default: { + plugins: { + logs: "warn", + mock: true, + fatal: true, + retries: { attempts: 1, delay: 0 }, + }, + processors: { + logs: "warn", + fatal: true, + retries: { attempts: 1, delay: 0 }, + }, + }, + }, +} as const + +/** Setup and teardown for components testing */ +export function setup(_: unknown) { + const { raw } = Logger + const { shareable } = Browser + Logger.raw = false + Browser.shareable = false + const teardown = () => { + Logger.raw = raw + Browser.shareable = shareable + } + return { teardown } +} + +/** Compute required permissions for components testing */ +export async function getPermissions(test: Awaited>[0]) { + // Aggregate permissions from all plugins and processors + const requested = new Set() + const plugins = new Set() + const processors = new Set() + for (const plugin of test.plugins) { + const { id } = sugar(plugin, "plugin") as { id?: string } + if (id) { + plugins.add(id) + } + if (plugin.processors) { + for (const processor of plugin.processors) { + const { id } = sugar(processor, "processor") as { id: string } + processors.add(id) + } + } + } + await Promise.all([...plugins].map((id) => Plugin.load({ id }).then((plugin) => plugin.permissions.forEach((permission) => requested.add(permission))))) + await Promise.all([...processors].map((id) => Processor.load({ id }).then((processor) => processor.permissions.forEach((permission) => requested.add(permission))))) + + // Compute permissions + const permissions = { + read: [dir.source, dir.cache], + env: [...requested].filter((permission) => permission.startsWith("env:")).map((permission) => permission.replace("env:", "")), + net: [...requested].filter((permission) => permission.startsWith("net:")).map((permission) => permission.replace("net:", "")), + run: [...requested].filter((permission) => permission.startsWith("run:")).map((permission) => permission.replace("run:", "")).filter((bin) => !["chrome"].includes(bin)), + } as test + if (requested.has("net:all")) { + permissions.net = "inherit" + } + // TODO(#1571) + if (permissions.run.length) { + permissions.run = "inherit" + } else if (requested.has("run:chrome")) { + Object.assign( + permissions, + deepMerge(permissions, { + read: [dir.cache], + net: ["127.0.0.1", "localhost"], + env: ["CHROME_BIN", "CHROME_PATH", "CHROME_EXTRA_FLAGS"], + run: [env.get("CHROME_BIN")], + write: [`${env.get("HOME")}/.config/chromium/SingletonLock`], + }), + ) + } + if (requested.has("write:tmp")) { + Object.assign(permissions, deepMerge(permissions, { write: [env.get("TMP")] })) + } + if (requested.has("read:tmp")) { + Object.assign(permissions, deepMerge(permissions, { read: [env.get("TMP")] })) + } + if (requested.has("write:all")) { + Object.assign(permissions, deepMerge(permissions, { write: [dir.test] })) + } + + return { permissions } +} + +/** Exports */ +export { Plugin, Processor } diff --git a/source/engine/components/tests/mock.graphql b/source/engine/components/tests/mock.graphql new file mode 100644 index 00000000000..0fcc09ff1ed --- /dev/null +++ b/source/engine/components/tests/mock.graphql @@ -0,0 +1,3 @@ +query Test($foo: Boolean!) { + bar +} diff --git a/source/engine/components/tests/mock.graphql.ts b/source/engine/components/tests/mock.graphql.ts new file mode 100644 index 00000000000..f01a67dab10 --- /dev/null +++ b/source/engine/components/tests/mock.graphql.ts @@ -0,0 +1,5 @@ +import { is, mock } from "@engine/utils/testing.ts" + +export default mock({ foo: is.boolean() }, ({ foo }) => ({ + bar: foo, +})) diff --git a/source/engine/components/tests/mock.http.ts b/source/engine/components/tests/mock.http.ts new file mode 100644 index 00000000000..d9f536640e1 --- /dev/null +++ b/source/engine/components/tests/mock.http.ts @@ -0,0 +1,5 @@ +import { mock } from "@engine/utils/testing.ts" + +export default mock({}, () => { + return "foo" +}) diff --git a/source/engine/components/tests/mock_invalid.graphql b/source/engine/components/tests/mock_invalid.graphql new file mode 100644 index 00000000000..0fcc09ff1ed --- /dev/null +++ b/source/engine/components/tests/mock_invalid.graphql @@ -0,0 +1,3 @@ +query Test($foo: Boolean!) { + bar +} diff --git a/source/engine/components/tests/mock_invalid.graphql.ts b/source/engine/components/tests/mock_invalid.graphql.ts new file mode 100644 index 00000000000..7b859548895 --- /dev/null +++ b/source/engine/components/tests/mock_invalid.graphql.ts @@ -0,0 +1 @@ +export default null diff --git a/source/engine/components/tests/mock_invalid.http.ts b/source/engine/components/tests/mock_invalid.http.ts new file mode 100644 index 00000000000..7b859548895 --- /dev/null +++ b/source/engine/components/tests/mock_invalid.http.ts @@ -0,0 +1 @@ +export default null diff --git a/source/engine/components/tests/mock_missing.graphql b/source/engine/components/tests/mock_missing.graphql new file mode 100644 index 00000000000..0fcc09ff1ed --- /dev/null +++ b/source/engine/components/tests/mock_missing.graphql @@ -0,0 +1,3 @@ +query Test($foo: Boolean!) { + bar +} diff --git a/source/engine/components/tests/mock_paginate.graphql b/source/engine/components/tests/mock_paginate.graphql new file mode 100644 index 00000000000..2ed5f52f42e --- /dev/null +++ b/source/engine/components/tests/mock_paginate.graphql @@ -0,0 +1,11 @@ +query Test($foo: Boolean!, $cursor: String) { + bar(after: $cursor) { + nodes { + value + } + pageInfo { + hasNextPage + endCursor + } + } +} diff --git a/source/engine/components/tests/mock_paginate_bad.graphql b/source/engine/components/tests/mock_paginate_bad.graphql new file mode 100644 index 00000000000..46f02b0eaf0 --- /dev/null +++ b/source/engine/components/tests/mock_paginate_bad.graphql @@ -0,0 +1,7 @@ +query Test($foo: Boolean!, $cursor: String) { + bar(after: $cursor) { + nodes { + value + } + } +} diff --git a/source/engine/components/tests/rest.ts b/source/engine/components/tests/rest.ts new file mode 100644 index 00000000000..ae9213cb4e4 --- /dev/null +++ b/source/engine/components/tests/rest.ts @@ -0,0 +1,39 @@ +import { mock, Status } from "@engine/utils/testing.ts" + +export default { + "/octocat": mock({}, () => ({ + status: Status.OK, + data: new TextEncoder().encode(` + MMM. .MMM + MMMMMMMMMMMMMMMMMMM + MMMMMMMMMMMMMMMMMMM _________________________________________ + MMMMMMMMMMMMMMMMMMMMM | | + MMMMMMMMMMMMMMMMMMMMMMM | Anything added dilutes everything else. | + MMMMMMMMMMMMMMMMMMMMMMMM |_ _____________________________________| + MMMM::- -:::::::- -::MMMM |/ + MM~:~ 00~:::::~ 00~:~MM +.. MMMMM::.00:::+:::.00::MMMMM .. + .MM::::: ._. :::::MM. + MMMM;:::::;MMMM + -MM MMMMMMM + ^ M+ MMMMMMMMM + MMMMMMM MM MM MM + MM MM MM MM + MM MM MM MM + .~~MM~MM~MM~MM~~. + ~~~~MM:~MM~~~MM~:MM~~~~ + ~~~~~~==~==~~~==~==~~~~~~ + ~~~~~~==~==~==~==~~~~~~ + :~==~==~==~==~~`), + })), + "/rate_limit": mock({}, () => ({ + status: Status.OK, + data: { + resources: { + core: { remaining: 0, limit: 0 }, + search: { remaining: 0, limit: 0 }, + graphql: { remaining: 0, limit: 0 }, + }, + }, + })), +} diff --git a/source/engine/components/tests/template.ejs b/source/engine/components/tests/template.ejs new file mode 100644 index 00000000000..592a8a8c9d2 --- /dev/null +++ b/source/engine/components/tests/template.ejs @@ -0,0 +1 @@ +<%= "foo" %> \ No newline at end of file diff --git a/source/engine/components/tests/tests/rest.ts b/source/engine/components/tests/tests/rest.ts new file mode 100644 index 00000000000..7b859548895 --- /dev/null +++ b/source/engine/components/tests/tests/rest.ts @@ -0,0 +1 @@ +export default null diff --git a/source/engine/config.ts b/source/engine/config.ts new file mode 100644 index 00000000000..b945f8efefc --- /dev/null +++ b/source/engine/config.ts @@ -0,0 +1,304 @@ +// Imports +import { is } from "@engine/utils/validation.ts" +import { deepMerge } from "std/collections/deep_merge.ts" +import { Secret } from "@engine/utils/secret.ts" +import { read } from "@engine/utils/deno/io.ts" +import { env } from "@engine/utils/deno/env.ts" +import * as YAML from "std/yaml/parse.ts" +import { Logger } from "@engine/utils/log.ts" +import { throws } from "@engine/utils/errors.ts" +import { filterKeys } from "std/collections/filter_keys.ts" + +/** Timezones */ +const timezones = [...Intl.supportedValuesOf("timeZone"), "UTC"] + +/** Default log levels */ +const loglevel = "trace" + +/** Secret */ +export const secret = is.union([is.unknown(), is.instanceof(Secret)]).transform((value) => value instanceof Secret ? value : new Secret(value)) + +/** Internal component config */ +export const internal = is.object({ + logs: is.union([ + is.literal("none").describe("Disable logs"), + is.literal("error").describe("Display error logs"), + is.literal("warn").describe("Display warning logs and higher"), + is.literal("info").describe("Display information logs and higher"), + is.literal("message").describe("Display message logs and higher"), + is.literal("debug").describe("Display debug logs and higher"), + is.literal("trace").describe("Display trace logs and higher"), + ]).describe("Log level"), +}) + +/** General component config */ +export const component = internal.extend({ + id: is.string().min(1).describe("Component identifier"), + args: is.record(is.string(), is.unknown()).describe("Component arguments"), + retries: is.object({ + attempts: is.number().int().positive().describe("Number of retries attempts"), + delay: is.number().min(0).describe("Delay between each retry attempts (in seconds)"), + }).describe("Retry policy"), + fatal: is.boolean().describe("Whether to stop on errors"), +}) + +/** Requests component config */ +export const requests = internal.extend({ + mock: is.boolean().describe("Whether to use mocked data instead"), + api: is.string().url().describe("GitHub API endpoint"), + token: secret.describe("GitHub token"), + timezone: is.string().min(1).refine((value) => timezones.includes(value)).describe("Timezone for dates"), +}) + +/** Plugin component internal config (without processors, to allow recursive typing within processor typing) */ +const _plugin_without_processors = component.merge(requests).extend({ + handle: is.string().min(1).nullable().describe("GitHub handle (e.g. `octocat`)"), + entity: is.union([ + is.literal("user").describe("GitHub user"), + is.literal("organization").describe("GitHub organization"), + is.literal("repository").describe("GitHub repository"), + ]).describe("GitHub entity type"), + template: is.string().min(1).nullable().describe("Template name or url. Set to `null` to skip rendering (placeholder: `classic`)"), +}) + +/** Processor component internal config */ +const _processor = component.extend({ parent: _plugin_without_processors }) + +/** Processor component internal config (without parent) */ +const _processor_without_parent = _processor.omit({ parent: true }) + +/** Processor component preset config */ +const _preset_processor = is.object({ + args: _processor.shape.args.default(() => ({})), + logs: _processor.shape.logs.default(loglevel), + fatal: _processor.shape.fatal.default(false), + retries: is.object({ + attempts: _processor.shape.retries.shape.attempts.default(1), + delay: _processor.shape.retries.shape.delay.default(120), + }).default(() => ({})), +}) + +/** List of processor allowed keys */ +const _processor_keys = [...Object.keys(_preset_processor.parse({}))] + +/** Processor component config */ +export const processor = is.preprocess((value) => { + const result = _preset_processor.extend({ id: _processor.shape.id, preset: is.string().optional() }).strict().safeParse(sugar(value, "processor")) + if (!result.success) { + return value + } + delete result.data.preset + return result.data +}, _processor_without_parent.strict()) as unknown as is.ZodObject + +/** Plugin component internal config */ +const _plugin = _plugin_without_processors.extend({ + processors: is.array(processor).describe("Post-processors"), +}) + +/** Plugin component preset config */ +const _preset_plugin = is.object({ + args: _plugin.shape.args.default(() => ({})), + logs: _plugin.shape.logs.default(loglevel), + api: _plugin.shape.api.default("https://api.github.com"), + token: _plugin.shape.token.default(() => env.get("METRICS_GITHUB_TOKEN")).describe("GitHub token (placeholder: `METRICS_GITHUB_TOKEN`)"), + handle: _plugin.shape.handle.default(null), + entity: _plugin.shape.entity.default("user"), + template: _plugin.shape.template.default("classic"), + timezone: _plugin.shape.timezone.default(() => Intl.DateTimeFormat().resolvedOptions().timeZone), + mock: _plugin.shape.mock.default(false), + processors: is.array(_processor_without_parent.deepPartial()).default(() => []), // _plugin.shape shouldn't be used here to avoid populating default values + fatal: _plugin.shape.fatal.default(false), + retries: is.object({ + attempts: _plugin.shape.retries.shape.attempts.default(3), + delay: _plugin.shape.retries.shape.delay.default(120), + }).default(() => ({})), +}) + +/** List of plugin allowed keys */ +const _plugin_keys = [...Object.keys(_preset_plugin.parse({}))] + +/** Nameless plugin */ +export const plugin_nameless = ".await" + +/** Plugin component config */ +export const plugin = is.preprocess((value) => { + const result = _preset_plugin.omit({ processors: true }).extend({ + id: _plugin.shape.id.default(plugin_nameless), + processors: is.array(is.unknown()).default(() => []), + preset: is.string().optional(), + }).strict().safeParse( + sugar(value, "plugin"), + ) + if (!result.success) { + return value + } + delete result.data.preset + return result.data +}, _plugin) as unknown as is.ZodObject + +/** Preset component config */ +export const preset = is.object({ + plugins: _preset_plugin.default(() => _preset_plugin.parse({})).describe("Default settings for plugins"), + processors: _preset_processor.default(() => _preset_processor.parse({})).describe("Default settings for processors"), +}) + +/** Internal config */ +const _config = is.object({ + plugins: is.array(plugin).describe("Plugins"), + presets: is.record(is.string(), preset).describe("Preset settings"), +}) + +/** Config */ +export const config = is.preprocess((_value) => { + const value = is.object({ + plugins: is.array( + is.object({ + preset: is.string().optional(), + processors: is.array( + is.object({ + preset: is.string().optional(), + }).passthrough(), + ).optional(), + }).passthrough(), + ).default(() => []), + presets: is.record(is.string(), is.record(is.string(), is.unknown())).default(() => ({})), + }).passthrough().parse(_value) + if (!value.presets.default) { + value.presets.default = preset.parse({}) + } + for (const plugin of value.plugins) { + Object.assign(plugin, merge(value.presets.default.plugins, value.presets[plugin.preset!]?.plugins, plugin)) + plugin.processors ??= [] + for (const processor of plugin.processors) { + Object.assign(processor, merge(value.presets.default.processors, value.presets[plugin.preset!]?.processors, value.presets[processor.preset!]?.processors, processor)) + } + } + return value +}, _config) as unknown as is.ZodObject + +/** CLI config */ +export const cli = internal.extend({ + logs: internal.shape.logs.default(loglevel), + check_updates: is.boolean().default(false).describe("Whether to check for updates on startup"), + config: config.default(() => ({} as is.infer)).describe("Metrics configuration"), +}) + +/** Server config */ +export const server = cli.extend({ + hostname: is.string().min(1).default("localhost").describe("Server hostname"), + port: is.number().int().min(1).max(65535).default(8080).describe("Server port"), + control: is.record( + is.string(), + is.object({ + stop: is.boolean().default(false).describe("Permission to stop the server via `POST /metrics/stop`"), + }), + ).nullable().default(null).describe("Control profiles (each profile is designed by a token bearer and dictionary of allowed routes)"), + cache: is.number().int().positive().nullable().default(null).describe("Cache duration for processed requests (in seconds)"), + limit: is.object({ + guests: is.object({ + max: is.number().int().min(0).nullable().default(null).describe("Maximum number of guests"), + requests: is.object({ + limit: is.number().int().positive().default(60).describe("Maximum number of requests per window duration per guest"), + duration: is.number().positive().default(60).describe("Window duration (in seconds)"), + ignore_mocked: is.boolean().default(true).describe("Whether to ignore mocked requests from rate limit"), + }).nullable().default(null).describe("Rate limit for guests"), + }).nullable().default(null).describe("Guests limitations"), + users: is.object({ + max: is.number().int().min(0).nullable().default(null).describe("Maximum number of logged users"), + requests: is.object({ + limit: is.number().int().positive().default(60).describe("Maximum number of requests per window duration per logged user"), + duration: is.number().positive().default(60).describe("Window duration (in seconds)"), + ignore_mocked: is.boolean().default(true).describe("Whether to ignore mocked requests from rate limit"), + }).nullable().default(null).describe("Rate limit for logged users"), + }).nullable().default(null).describe("Logged users limitations"), + }).default(() => ({})), + github_app: is.object({ + id: is.number().int().positive().describe("GitHub app identifier"), + private_key_path: is.string().describe("Path to GitHub app private key file (must be in PKCS#8 format) (placeholder: `.secrets/github-app-private-key-pk8s.pem`)"), + client_id: is.string().describe("GitHub app client identifier (placeholder: `Iv1.c533685fd0d2d002`)"), + client_secret: secret.default(() => env.get("METRICS_GITHUB_APP_SECRET")).describe("GitHub app client secret (placeholder: `METRICS_GITHUB_APP_SECRET`)"), + }).nullable().default(null).describe("GitHub app settings"), +}) + +/** Web request config */ +export const webrequest = is.object({ + mock: is.boolean().default(false).describe("Whether to use mocked data"), + plugins: is.array( + is.preprocess( + (value) => sugar(value, "plugin"), + _plugin.pick({ + id: true, + timezone: true, + handle: true, + fatal: true, + entity: true, + template: true, + args: true, + }).partial().extend({ + preset: is.string().optional(), + processors: is.array( + is.preprocess( + (value) => sugar(value, "processor"), + _processor.pick({ + id: true, + fatal: true, + args: true, + }).partial().extend({ preset: is.string().optional() }), + ), + ).default(() => []).describe("Post-processors"), + }), + ), + ).default(() => []).describe("Plugins"), +}) + +/** Merge deeply objects */ +function merge(...objects: unknown[]) { + let result = {} as Record + for (const object of objects) { + result = deepMerge(result, (object ?? {}) as Record, { arrays: "replace" }) + } + return result +} + +/** Syntaxic sugar for components that allows the use of one extra dictionnary as identifier and args */ +export function sugar(value: unknown, _keys: "processor" | "plugin") { + const keys = Array.isArray(_keys) ? _keys : { processor: _processor_keys, plugin: _plugin_keys }[_keys] + if ((!value) || (typeof value !== "object") || ("id" in value)) { + return value + } + const record = { ...value } as Record + if ((record.args) && (typeof record.args === "object") && (Object.keys(record.args).length)) { + return value + } + const [id, ...extras] = Object.keys(filterKeys(record, (key) => !keys.includes(key))) + if (extras.length) { + return { id: "", ...value } + } + if (id === undefined) { + return value + } + const args = record[id] ?? {} + delete record[id] + return Object.assign(record, { id, args }) +} + +/** Load config */ +export async function load(path = "metrics.config.yml") { + const log = new Logger(import.meta) + const config = {} as Record + try { + const parsed = await YAML.parse(await read(path)) + if (typeof parsed !== "object") { + throws("Expected configuration to be a dictionary") + } + Object.assign(config, parsed) + } catch (error) { + if ((globalThis.Deno) && ((error instanceof Deno.errors.NotFound) || (error instanceof Deno.errors.PermissionDenied))) { + log.warn(`${path} not found or not readable, using default configuration`) + } else { + throw error + } + } + return config +} diff --git a/source/engine/config_test.ts b/source/engine/config_test.ts new file mode 100644 index 00000000000..1e958633f62 --- /dev/null +++ b/source/engine/config_test.ts @@ -0,0 +1,157 @@ +import { expect, MetricsError, t } from "@engine/utils/testing.ts" +import { cli, config, load, server, webrequest } from "@engine/config.ts" +import { MetricsValidationError, parse } from "@engine/utils/validation.ts" + +Deno.test(t(import.meta, "`parse(config)` is parseable"), { permissions: "none" }, async () => { + await expect(parse(config, { config: { plugins: [{ id: "foo", args: { bar: true } }] } })).to.be.fulfilled +}) + +Deno.test(t(import.meta, "`parse(config)` honors default preset"), { permissions: "none" }, async () => { + const presets = { + default: { + plugins: { + args: { + foo: true, + }, + }, + }, + } + await expect(parse(config, { presets, plugins: [{ id: "foo" }] })).to.be.fulfilled.and.eventually.containSubset({ + plugins: [{ + id: "foo", + args: { + foo: true, + }, + }], + }) +}) + +{ + const presets = { + default: { + plugins: { + logs: "message", + }, + processors: { + logs: "info", + }, + }, + A: { + plugins: { + args: { + foo: true, + }, + processors: [ + { + id: "bar", + }, + ], + }, + processors: { + args: { + bar: false, + }, + }, + }, + B: { + processors: { + logs: "warn", + args: { + bar: true, + }, + }, + }, + } + Deno.test(t(import.meta, "`parse(config)` honors named presets with default preset overriden by named preset"), { permissions: "none" }, async () => { + await expect(parse(config, { presets, plugins: [{ id: "foo", preset: "A" }] })).to.be.fulfilled.and.eventually.containSubset({ + plugins: [ + { + id: "foo", + logs: "message", + args: { + foo: true, + }, + processors: [ + { + id: "bar", + logs: "info", + args: { + bar: false, + }, + }, + ], + }, + ], + }) + }) + Deno.test(t(import.meta, "`parse(config)` honors named presets with named preset overriden by another named preset"), { permissions: "none" }, async () => { + await expect(parse(config, { presets, plugins: [{ id: "foo", preset: "A", processors: [{ id: "bar", preset: "B" }] }] })).to.be.fulfilled.and.eventually.containSubset({ + plugins: [ + { + id: "foo", + logs: "message", + args: { + foo: true, + }, + processors: [ + { + id: "bar", + logs: "warn", + args: { + bar: true, + }, + }, + ], + }, + ], + }) + }) + Deno.test(t(import.meta, "`parse(config)` supports syntaxic sugar for plugins and processors by passing id as a single extra object property"), { permissions: "none" }, async () => { + const expected = { + plugins: [ + { + id: "foo", + args: { + bar: true, + }, + }, + ], + } + await expect(parse(config, { plugins: [{ id: "foo", args: { bar: true } }] })).to.be.fulfilled.and.eventually.containSubset(expected) + await expect(parse(config, { plugins: [{ foo: { bar: true } }] })).to.be.fulfilled.and.eventually.containSubset(expected) + await expect(parse(config, { plugins: [{}] })).to.be.fulfilled.and.eventually.containSubset({ plugins: [{}] }) + }) + Deno.test(t(import.meta, "`parse(config)` supports syntaxic sugar for plugins and processors but throws when ambiguous"), { permissions: "none" }, async () => { + await expect(parse(config, { plugins: [{ foo: {}, args: { bar: true } }] })).to.be.rejectedWith(MetricsValidationError) + await expect(parse(config, { plugins: [{ foo: {}, bar: {} }] })).to.be.rejectedWith(MetricsValidationError) + await expect(parse(config, { plugins: [{ foo: {}, processors: [{ foo: {}, bar: {} }] }] })).to.be.rejectedWith(MetricsValidationError) + }) +} + +Deno.test(t(import.meta, "`parse(cli)` is parseable and has defaults"), { permissions: "none" }, async () => { + await expect(parse(cli, {})).to.be.fulfilled +}) + +Deno.test(t(import.meta, "`parse(server)` is parseable and has defaults"), { permissions: "none" }, async () => { + await expect(parse(server, {})).to.be.fulfilled + await expect(parse(server, { github_app: { id: 1, private_key_path: "/dev/null", client_id: "" } })).to.be.fulfilled +}) + +Deno.test(t(import.meta, "`parse(webrequest)` is parseable and has defaults"), { permissions: "none" }, async () => { + await expect(parse(webrequest, {})).to.be.fulfilled + await expect(parse(webrequest, { plugins: [{}] })).to.be.fulfilled.and.eventually.containSubset({ plugins: [{}] }) + await expect(parse(webrequest, { plugins: [{ foo: {} }] })).to.be.fulfilled.and.eventually.containSubset({ plugins: [{ id: "foo" }] }) + await expect(parse(webrequest, { plugins: [{ foo: {}, processors: [{ bar: {} }] }] })).to.be.fulfilled.and.eventually.containSubset({ plugins: [{ id: "foo", processors: [{ id: "bar" }] }] }) +}) + +Deno.test(t(import.meta, "`load()` reads file and returns parsed config"), { permissions: "none" }, async () => { + await expect(load(`data:text/plain;base64,${btoa('{"logs":"message"}')}`)).to.eventually.deep.equal({ logs: "message" }) +}) + +Deno.test(t(import.meta, "`load()` returns default config if file is unreadable"), { permissions: "none" }, async () => { + await expect(load("/unreadable_config_file.metrics.yml")).to.eventually.fulfilled +}) + +Deno.test(t(import.meta, "`load()` throws if not a dictionary"), { permissions: "none" }, async () => { + await expect(load(`data:text/plain;base64,${btoa("foo")}`)).to.be.rejectedWith(MetricsError, /expected configuration to be a dictionary/i) +}) diff --git a/source/engine/metadata.ts b/source/engine/metadata.ts new file mode 100644 index 00000000000..9d647a0be81 --- /dev/null +++ b/source/engine/metadata.ts @@ -0,0 +1,37 @@ +//Imports +import { Plugin } from "@engine/components/plugin.ts" +import { Processor } from "@engine/components/processor.ts" +import { cli, preset, server, webrequest } from "@engine/config.ts" +import { toSchema } from "@engine/utils/validation.ts" +import { version } from "@engine/version.ts" + +/** Metadata */ +export async function metadata() { + const metadata = { + version: version.number, + plugins: [] as Record[], + processors: [] as Record[], + modes: { + web: toSchema(webrequest.omit({ plugins: true })), + actions: toSchema(cli.omit({ config: true })), + server: toSchema(server.omit({ config: true })), + }, + presets: { + schema: toSchema(preset), + }, + } + + // Populate plugins + for (const id of await Plugin.list()) { + const plugin = await Plugin.load({ id }) + metadata.plugins.push(plugin.metadata) + } + + // Populate processors + for (const id of await Processor.list()) { + const processor = await Processor.load({ id }) + metadata.processors.push(processor.metadata) + } + + return metadata +} diff --git a/source/engine/metadata_test.ts b/source/engine/metadata_test.ts new file mode 100644 index 00000000000..d9aa34e3e8f --- /dev/null +++ b/source/engine/metadata_test.ts @@ -0,0 +1,6 @@ +import { dir, expect, t } from "@engine/utils/testing.ts" +import { metadata } from "@engine/metadata.ts" + +Deno.test(t(import.meta, "`metadata()` returns metadata"), { permissions: { read: [dir.source], net: "inherit" } }, async () => { + await expect(metadata()).to.eventually.include.keys("version", "plugins", "processors", "modes") +}) diff --git a/source/engine/paths.ts b/source/engine/paths.ts new file mode 100644 index 00000000000..ed30c850730 --- /dev/null +++ b/source/engine/paths.ts @@ -0,0 +1,14 @@ +// Imports +import { fromFileUrl } from "std/path/from_file_url.ts" + +/** Project root path */ +export const root = fromFileUrl(new URL("../..", import.meta.url)).replaceAll("\\", "/").replace(/\/$/, "") + +/** Source path */ +export const source = `${root}/source` + +/** Test path */ +export const test = `${root}/.test` + +/** Cache path */ +export const cache = `${root}/.cache` diff --git a/source/engine/paths_test.ts b/source/engine/paths_test.ts new file mode 100644 index 00000000000..34a22a75a3c --- /dev/null +++ b/source/engine/paths_test.ts @@ -0,0 +1,10 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { root } from "@engine/paths.ts" + +Deno.test(t(import.meta, "`root` is correct"), { permissions: { read: [root] } }, async () => { + const files = [] + for await (const { name } of Deno.readDir(root)) { + files.push(name) + } + expect(files).to.include.members(["deno.jsonc"]) +}) diff --git a/source/engine/process.ts b/source/engine/process.ts new file mode 100644 index 00000000000..3971e1386b2 --- /dev/null +++ b/source/engine/process.ts @@ -0,0 +1,35 @@ +// Imports +import { Plugin } from "@engine/components/plugin.ts" +import { config as schema } from "@engine/config.ts" +import { parse } from "@engine/utils/validation.ts" + +/** Process metrics */ +export async function process(_config: Record) { + const config = await parse(schema, _config) + const pending = new Set>() + const state = await parse(Plugin.state, { results: [], errors: [] }) + let result = undefined as typeof state.result + + // Process config + for (const [tracker, plugin] of Object.entries(config.plugins)) { + switch (plugin.id) { + case Plugin.nameless: { + await Promise.all([...pending]) + const { result: _result } = await Plugin.run({ tracker, context: plugin, state }) + result = _result + pending.clear() + continue + } + default: { + pending.add(Plugin.run({ tracker, context: plugin, state })) + continue + } + } + } + const results = await Promise.all([...pending]) as Array<{ result: typeof state.result }> + if (!result) { + result = results.at(-1)?.result + } + + return result ?? null +} diff --git a/source/engine/process_test.ts b/source/engine/process_test.ts new file mode 100644 index 00000000000..b76d711a1d6 --- /dev/null +++ b/source/engine/process_test.ts @@ -0,0 +1,13 @@ +import { expect, MetricsError, t } from "@engine/utils/testing.ts" +import { process } from "@engine/process.ts" +import * as dir from "@engine/paths.ts" + +Deno.test(t(import.meta, "`process()` returns processed config"), { permissions: { read: [dir.source] } }, async () => { + await expect(process({})).to.eventually.be.null + await expect(process({ plugins: [{ logs: "none" }] })).to.eventually.have.property("result") + await expect(process({ plugins: [{ id: "introduction", handle: "octocat", logs: "none", mock: true }] })).to.eventually.have.property("result") +}) + +Deno.test(t(import.meta, "`process()` throws on unknown component"), { permissions: { read: [dir.source] } }, async () => { + await expect(process({ plugins: [{ id: "foo", logs: "none" }] })).to.rejectedWith(MetricsError, /could not be loaded/i) +}) diff --git a/source/engine/utils/browser.ts b/source/engine/utils/browser.ts new file mode 100644 index 00000000000..fe972939e21 --- /dev/null +++ b/source/engine/utils/browser.ts @@ -0,0 +1,187 @@ +//Imports +import { Logger } from "@engine/utils/log.ts" +import { getBinary, launch } from "x/astral@0.3.2/mod.ts" +import { env } from "@engine/utils/deno/env.ts" +import * as dir from "@engine/paths.ts" +import { throws } from "@engine/utils/errors.ts" +import { delay } from "std/async/delay.ts" + +/** Browser */ +export class Browser { + /** Constructor */ + constructor({ log, endpoint = null, bin = env.get("CHROME_BIN") }: { log: Logger; endpoint?: null | string; bin?: string }) { + this.endpoint = endpoint + this.log = log + this.bin = bin + this.ready = this.open() + } + + /** Is ready ? */ + readonly ready + + /** Logger */ + private readonly log + + /** Browser binary path */ + private readonly bin + + /** Remote browser endpoint */ + readonly endpoint + + /** Browser instance */ + #instance = null as null | Awaited> + + /** Open browser instance */ + private async open() { + if (this.endpoint) { + this.#instance = await launch({ wsEndpoint: this.endpoint }) + this.log.io(`using remote browser: ${this.endpoint}`) + } else { + this.#instance = await launch({ args: Browser.flags, path: this.bin, cache: dir.cache }) + Object.assign(this, { endpoint: this.#instance.wsEndpoint() }) + this.log.io(`using local browser: ${this.endpoint} (from ${this.bin})`) + } + return this + } + + /** Close browser instance */ + async close() { + if (this.#instance) { + await this.#instance.close() + this.#instance = null + this.log.io("closed browser") + } + } + + /** Spawn a new page */ + async page({ width, height, url }: { width?: number; height?: number; url?: string } = {}) { + await this.ready + if (!this.#instance) { + throws("Browser has no instance attached") + } + const page = await this.#instance.newPage(url) + if ((typeof width === "number") && (typeof height === "number")) { + page.setViewportSize({ width, height }) + } + const close = page.close.bind(page) + const evaluate = page.evaluate.bind(page) + Object.assign(page, { + // Close page (and possibly browser) + close: async () => { + await close() + this.log.io("closed browser page") + if (!Browser.shareable) { + await this.close() + } + }, + // Evaluate function + evaluate: (async (func: Parameters[0], options?: Parameters[1]) => { + try { + if ((typeof func === "string") && (func.startsWith("dom://"))) { + let caller = "" + const { prepareStackTrace } = Error + try { + const error = new Error() + Error.prepareStackTrace = (_, stack) => stack + const stack = (error.stack as unknown as Array<{ getFileName(): string }>) + .map((callsite) => callsite.getFileName()) + .filter((file) => file) + .filter((file) => file !== import.meta.url) + .filter((file) => !file.startsWith("ext:")) + caller = stack[0] + } finally { + Object.assign(Error, { prepareStackTrace }) + } + this.log.trace(`${func} was invoked from ${caller}`) + const url = new URL(`dom/${func.replace("dom://", "")}`, caller).href + this.log.trace(`importing: ${url}`) + const { default: script } = await import(url) + func = script + } + return await evaluate(func, options) + } catch (error) { + throws(error.text) + } + }) as typeof evaluate, + // Set transparent background + setTransparentBackground: async () => { + const celestial = page.unsafelyGetCelestialBindings() + await celestial.Emulation.setDefaultBackgroundColorOverride({ color: { r: 0, b: 0, g: 0, a: 0 } }) + await delay(100) + }, + }) + this.log.io("opened new browser page") + return page as typeof page & { setTransparentBackground: () => Promise } + } + + /** Shared browser instance */ + static readonly shared = null as null | Browser + + /** Should reuse static browser instance when possible? */ + static shareable = true + + /** Instantiates or reuse */ + static async page({ log, bin, width, height }: { log: Logger; bin?: string; width?: number; height?: number }) { + if ((Browser.shareable) && (!Browser.shared)) { + Object.assign(Browser, { shared: await new Browser({ log: new Logger(import.meta, { level: "none" }), bin, endpoint: env.get("BROWSER_ENDPOINT") }).ready }) + const close = Browser.shared!.close.bind(Browser.shared) + Browser.shared!.close = async () => { + await close() + Object.assign(Browser, { shared: null }) + log.io("closed shared browser instance") + } + log.io("opened shared browser instance") + } + const browser = await new Browser({ log, bin, endpoint: Browser.shared?.endpoint }).ready + return browser.page({ width, height }) + } + + /** Get binary path */ + static getBinary(product: Parameters[0]) { + return getBinary(product, { cache: dir.cache }) + } + + /** Browser flags */ + private static get flags() { + const flags = [ + "--disable-audio-input", + "--disable-audio-output", + "--disable-auto-reload", + "--disable-breakpad", + "--disable-component-extensions-with-background-pages", + "--disable-component-update", + "--disable-default-apps", + "--disable-dinosaur-easter-egg", + "--disable-extensions", + "--disable-file-system", + "--disable-gpu", + "--disable-input-event-activation-protection", + "--disable-ios-password-suggestions", + "--disable-lazy-loading", + "--disable-local-storage", + "--disable-notifications", + "--disable-pdf-tagging", + "--disable-permissions-api", + "--disable-plugins", + "--disable-plugins-discovery", + "--disable-presentation-api", + "--disable-prompt-on-repost", + "--disable-shared-workers", + "--disable-software-rasterizer", + "--disable-speech-api", + "--disable-speech-synthesis-api", + "--disable-stack-profiler", + "--disable-sync", + "--disable-touch-drag-drop", + "--disable-translate", + "--hide-scrollbars", + "--incognito", + "--no-experiments", + "--no-pings", + "--no-referrers", + "--window-size=1000,1000", + ...env.get("CHROME_EXTRA_FLAGS").split(" "), + ] + return flags + } +} diff --git a/source/engine/utils/browser_test.ts b/source/engine/utils/browser_test.ts new file mode 100644 index 00000000000..225e78bc5fe --- /dev/null +++ b/source/engine/utils/browser_test.ts @@ -0,0 +1,113 @@ +/// +import { Browser } from "@engine/utils/browser.ts" +import { dir, expect, MetricsError, t } from "@engine/utils/testing.ts" +import { Logger } from "@engine/utils/log.ts" +import { Format } from "@engine/utils/format.ts" +import { env } from "@engine/utils/deno/env.ts" + +const log = new Logger(import.meta, { level: "none" }) +const cache = `${dir.cache}/browser.test` +const bin = env.get("CHROME_BIN") +const permissions = { + read: [cache], + net: ["127.0.0.1", "localhost"], + env: ["CHROME_BIN", "CHROME_PATH", "CHROME_EXTRA_FLAGS"], + run: [bin], + write: [`${env.get("HOME")}/.config/chromium/SingletonLock`, env.get("TMP")], +} + +Deno.test(t(import.meta, "`static .getBinary()` returns a path"), { + permissions: { + net: [ + "googlechromelabs.github.io/chrome-for-testing", + "edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing", + ], + read: [dir.cache], + write: [dir.cache], + }, +}, async () => { + await expect(Browser.getBinary("chrome")).to.eventually.be.a("string") +}) + +for (const mode of ["local", "remote"]) { + const setup = async () => { + const remote = (mode === "remote") ? await new Browser({ log, bin }).ready : null + const browser = await new Browser({ log, endpoint: remote?.endpoint, bin }).ready + const teardown = () => Promise.all([browser.close(), remote?.close()]) + return { browser, teardown } + } + + // TODO(@lowlighter): renable these tests + Deno.test.ignore(t(import.meta, `\`${mode} .page()\` returns a new page`), { permissions }, async () => { + const { browser, teardown } = await setup() + for (let i = 0; i < 2; i++) { + const page = await browser.page() + await page.goto(`data:text/html,${Format.html("")}`) + await expect(page.evaluate("document.title")).to.eventually.equal("Metrics") + await page.close() + } + await teardown() + }) + + Deno.test.ignore(t(import.meta, `\`${mode} .page()\` throws when browser is already closed`), { permissions }, async () => { + const { browser, teardown } = await setup() + await browser.close() + await expect(browser.page()).to.be.rejectedWith(MetricsError, /browser has no instance/i) + await teardown() + }) +} + +Deno.test(t(import.meta, "`shared .page()` returns a new page"), { permissions }, async () => { + const { shareable } = Browser + Browser.shareable = true + for (let i = 0; i < 2; i++) { + const page = await Browser.page({ log, bin }) + await page.goto(`data:text/html,${Format.html("")}`) + await expect(page.evaluate("document.title")).to.eventually.equal("Metrics") + await page.close() + } + await Browser.shared?.close() + expect(Browser.shared).to.be.null + Browser.shareable = shareable +}) + +Deno.test(t(import.meta, "`shared .page()` returns a new page even after browser was already closed"), { permissions }, async () => { + const { shareable } = Browser + Browser.shareable = true + await Browser.shared?.close() + expect(Browser.shared).to.be.null + const page = await Browser.page({ log, bin }) + await page.close() + await Browser.shared?.close() + expect(Browser.shared).to.be.null + Browser.shareable = shareable +}) + +Deno.test(t(import.meta, "`page.setTransparentBackground()` sets a transparent background"), { permissions }, async () => { + const { shareable } = Browser + Browser.shareable = true + await Browser.shared?.close() + expect(Browser.shared).to.be.null + const page = await Browser.page({ log, bin }) + await page.setTransparentBackground() + await page.close() + await Browser.shared?.close() + expect(Browser.shared).to.be.null + Browser.shareable = shareable +}) + +Deno.test(t(import.meta, "`page.evaluate()` runs scripts"), { permissions }, async () => { + const { shareable } = Browser + Browser.shareable = true + await Browser.shared?.close() + expect(Browser.shared).to.be.null + const page = await Browser.page({ log, bin, width: 800, height: 600 }) + await expect(page.evaluate(() => { + throw new Error("Expected error") + })).to.be.rejected + await expect(page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }))).to.eventually.deep.equal({ width: 800, height: 600 }) + await page.close() + await Browser.shared?.close() + expect(Browser.shared).to.be.null + Browser.shareable = shareable +}) diff --git a/source/engine/utils/deno/command.ts b/source/engine/utils/deno/command.ts new file mode 100644 index 00000000000..7998bddf758 --- /dev/null +++ b/source/engine/utils/deno/command.ts @@ -0,0 +1,32 @@ +// Imports +import argv from "y/string-argv@0.3.1?pin=v133" +import { Logger } from "@engine/utils/log.ts" +import { TextDelimiterStream } from "std/streams/text_delimiter_stream.ts" + +/** Execute command */ +export async function command(input: string, options: { return: "stdout" | "stderr"; cwd?: string; env?: Record; log?: Logger }): Promise +export async function command(input: string, options?: { cwd?: string; env?: Record; log?: Logger }): Promise<{ success: boolean; code: number; stdout: string; stderr: string }> +export async function command(input: string, { return: returned, cwd, log, env }: { return?: "stdout" | "stderr"; cwd?: string; env?: Record; log?: Logger } = {}) { + const stdio = { stdout: "", stderr: "" } + const [bin, ...args] = argv(input) + log = log?.with({ bin }) + const command = new Deno.Command(bin, { args, stdin: "null", stdout: "piped", stderr: "piped", cwd, env }) + const process = command.spawn() + const streams = Promise.allSettled((["stdout", "stderr"] as const).map(async (channel) => { + const stream = process[channel].pipeThrough(new TextDecoderStream()).pipeThrough(new TextDelimiterStream("\n")) + try { + for await (const line of stream) { + log?.[({ stdout: "message", stderr: "warn" } as const)[channel]](line) + stdio[channel] += line + "\n" + } + } finally { + stream.cancel() + } + })) + const { success, code } = await process.status + await streams + if (returned) { + return stdio[returned].trim() + } + return { success, code, ...stdio } +} diff --git a/source/engine/utils/deno/command_test.ts b/source/engine/utils/deno/command_test.ts new file mode 100644 index 00000000000..459f65debbb --- /dev/null +++ b/source/engine/utils/deno/command_test.ts @@ -0,0 +1,24 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { command } from "@engine/utils/deno/command.ts" +import { Logger } from "@engine/utils/log.ts" +import { DevNull } from "@engine/utils/log_test.ts" + +const stdio = new DevNull() +const log = new Logger(import.meta, { level: Logger.channels.trace, tags: { foo: "bar" }, stdio }) + +// TODO(#1571) +const permissions = { run: "inherit" } as const + +Deno.test(t(import.meta, "`command()` can execute commands"), { permissions }, async () => { + await expect(command("deno --version")).to.be.eventually.containSubset({ success: true, code: 0 }) +}) + +Deno.test(t(import.meta, "`command()` returns stdio content instead if asked"), { permissions }, async () => { + await expect(command("deno --version", { return: "stdout" })).to.eventually.include("deno") +}) + +Deno.test(t(import.meta, "`command()` returns stdio content instead if asked"), { permissions }, async () => { + stdio.flush() + await expect(command("deno --version", { log })).to.be.fulfilled + expect(stdio.messages).to.not.be.empty +}) diff --git a/source/engine/utils/deno/env.ts b/source/engine/utils/deno/env.ts new file mode 100644 index 00000000000..8f55a5b8d77 --- /dev/null +++ b/source/engine/utils/deno/env.ts @@ -0,0 +1,38 @@ +/** Get environment value */ +function get(key: string, options: { boolean: true }): boolean +function get(key: string, options?: { boolean: false }): string +function get(key: string, { boolean = false } = {}) { + if (boolean) { + const value = env.get(key, { boolean: false }) + if (!value.length) { + return false + } + return !/^(0|[Nn]o?|NO|[Oo]ff|OFF|[Ff]alse|FALSE)$/.test(value) + } + try { + return Deno.env.get(key) ?? "" + } catch { + return "" + } +} + +/** Set environment value */ +function set(key: string, value: string) { + try { + return Deno.env.set(key, value) + } catch { + return + } +} + +/** Environment */ +export const env = { + get, + set, + get deployment() { + return env.get("DENO_DEPLOYMENT_ID", { boolean: true }) + }, + get actions() { + return env.get("GITHUB_ACTIONS", { boolean: true }) + }, +} diff --git a/source/engine/utils/deno/env_test.ts b/source/engine/utils/deno/env_test.ts new file mode 100644 index 00000000000..a4bc643852a --- /dev/null +++ b/source/engine/utils/deno/env_test.ts @@ -0,0 +1,43 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { env } from "@engine/utils/deno/env.ts" + +const uuid = crypto.randomUUID().slice(-12).toUpperCase() +const test = { + env: "METRIC_ENV_TEST", + value: uuid, +} + +Deno.test(t(import.meta, "`env.get()` can read a env variable"), { permissions: { env: [test.env, `${test.env}_UNDEFINED`] } }, () => { + env.set(test.env, test.value) + expect(env.get(test.env)).to.equal(test.value) + expect(env.get(`${test.env}_UNDEFINED`)).to.equal("") +}) + +Deno.test(t(import.meta, "`env.get()` returns a env variable as a boolean if asked"), { permissions: { env: [test.env] } }, () => { + for ( + const { value, boolean } of [ + { value: "1", boolean: true }, + { value: "true", boolean: true }, + { value: "0", boolean: false }, + { value: "false", boolean: false }, + ] + ) { + env.set(test.env, value) + expect(env.get(test.env, { boolean: true })).to.equal(boolean) + } +}) + +Deno.test(t(import.meta, "`env.set()` can registers a env variable"), { permissions: { env: [test.env] } }, () => { + expect(env.set(test.env, test.value)) + expect(env.get(test.env)).to.equal(test.value) + expect(env.set(`${test.env}_FORBIDDEN`, test.value)) + expect(env.get(`${test.env}_FORBIDDEN`)).to.equal("") +}) + +Deno.test(t(import.meta, "`env.deployment` is a boolean"), { permissions: { env: [test.env, `${test.env}_UNDEFINED`] } }, () => { + expect(env.deployment).to.be.a("boolean") +}) + +Deno.test(t(import.meta, "`env.actions` is a boolean"), { permissions: { env: [test.env, `${test.env}_UNDEFINED`] } }, () => { + expect(env.actions).to.be.a("boolean") +}) diff --git a/source/engine/utils/deno/io.ts b/source/engine/utils/deno/io.ts new file mode 100644 index 00000000000..677ea484962 --- /dev/null +++ b/source/engine/utils/deno/io.ts @@ -0,0 +1,57 @@ +//Imports +import { ensureDir } from "std/fs/ensure_dir.ts" +import { expandGlob, expandGlobSync } from "std/fs/expand_glob.ts" +import { throws } from "@engine/utils/errors.ts" +import { dirname } from "std/path/dirname.ts" +import * as dir from "@engine/paths.ts" + +/** Read file */ +export function read(path: string | URL, options: { sync: true }): string +export function read(path: string | URL, options?: { sync?: false }): Promise +export function read(path: string | URL, { sync = false } = {}) { + if ((typeof path === "string") && (path.startsWith("metrics://"))) { + path = path.replace("metrics:/", dir.source) + } else if ((path instanceof URL) && (path.protocol === "metrics:")) { + path = path.href.replace("metrics:/", dir.source) + } + if ((typeof path === "string") && (path.startsWith("data:"))) { + if (sync) { + throws("Unsupported action: synchronous read") + } + return fetch(path).then((response) => response.text()) + } + return sync ? Deno.readTextFileSync(path) : Deno.readTextFile(path) +} + +/** Write file */ +export async function write(path: string, data: string | Uint8Array | ReadableStream) { + if (path === "/dev/null") { + return + } + await ensureDir(dirname(path)) + if (typeof data === "string") { + return Deno.writeTextFile(path, data) + } + return Deno.writeFile(path, data) +} + +/** List files in globpath */ +export function list(glob: string, options: { sync: true }): string[] +export function list(glob: string, options?: { sync?: false }): Promise +export function list(glob: string, { sync = false } = {}) { + const files = [] + const base = glob.match(/(?.*\/)\*/)?.groups?.base + const prefix = new RegExp(`.*?${base}`) + if (sync) { + for (const { path } of expandGlobSync(glob, { extended: true, globstar: true })) { + files.push(path.replaceAll("\\", "/").replace(prefix, "")) + } + return files + } + return (async () => { + for await (const { path } of expandGlob(glob, { extended: true, globstar: true })) { + files.push(path.replaceAll("\\", "/").replace(prefix, "")) + } + return files + })() +} diff --git a/source/engine/utils/deno/io_test.ts b/source/engine/utils/deno/io_test.ts new file mode 100644 index 00000000000..8ba9e087bca --- /dev/null +++ b/source/engine/utils/deno/io_test.ts @@ -0,0 +1,47 @@ +import { dir, expect, MetricsError, t } from "@engine/utils/testing.ts" +import { list, read, write } from "@engine/utils/deno/io.ts" +import { fromFileUrl } from "std/path/from_file_url.ts" + +const uuid = crypto.randomUUID().slice(-12).toUpperCase() +const test = { + file: `${dir.test}/io_test.${uuid}.txt`, + kv: `${dir.test}/io_test_kv.${uuid}.kv`, + env: `UNIT_TEST_${uuid}`, + port: 9000, +} + +Deno.test(t(import.meta, "`write()` can write a text file"), { permissions: { read: [dir.test], write: [dir.test] } }, async () => { + await expect(write(test.file, uuid)).to.be.a("promise").and.to.be.fulfilled +}) + +Deno.test(t(import.meta, "`write()` can write a raw file"), { permissions: { read: [dir.test], write: [dir.test] } }, async () => { + await expect(write(test.file, new TextEncoder().encode(uuid))).to.be.a("promise").and.to.be.fulfilled +}) + +Deno.test(t(import.meta, "`write()` ignores writing to `/dev/null`"), { permissions: "none" }, async () => { + await expect(write("/dev/null", new TextEncoder().encode(uuid))).to.be.a("promise").and.to.be.fulfilled +}) + +Deno.test(t(import.meta, "`read()` can read a raw file"), { permissions: { read: [dir.test] } }, async () => { + await expect(read(test.file)).to.be.a("promise").and.to.be.fulfilled.and.to.eventually.be.a("string") + expect(read(test.file, { sync: true })).to.be.a("string").and.to.equal(uuid) +}) + +Deno.test(t(import.meta, "`read()` can read a data url"), { permissions: "none" }, async () => { + const data = `data:text/plain;base64,${btoa("hello")}` + await expect(read(data)).to.be.a("promise").and.to.be.fulfilled.and.to.eventually.be.a("string") + expect(() => read(data, { sync: true })).to.throw(MetricsError, /unsupported action/i) +}) + +Deno.test(t(import.meta, "`read()` converts `metrics://` scheme to `/source`"), { permissions: { read: [dir.source] } }, async () => { + const url = fromFileUrl(import.meta.url).replaceAll("\\", "/") + const expected = await read(url) + const test = url.replace(dir.source, "metrics:/") + await expect(read(test)).to.eventually.equal(expected) + await expect(read(new URL(test))).to.eventually.equal(expected) +}) + +Deno.test(t(import.meta, "`list()` can list files in a directory"), { permissions: { read: [dir.source] } }, async () => { + expect(await list(`${dir.source}/engine/utils/deno/*.ts`)).to.include.members(["io.ts", "io_test.ts"]) + expect(await list(`${dir.source}/*.ts`)).to.not.include.members(["io.ts", "io_test.ts"]) +}) diff --git a/source/engine/utils/deno/server.ts b/source/engine/utils/deno/server.ts new file mode 100644 index 00000000000..ec141ef880e --- /dev/null +++ b/source/engine/utils/deno/server.ts @@ -0,0 +1,67 @@ +/// +//Imports +import { throws } from "@engine/utils/errors.ts" + +/** Port listener */ +export function listen(options: Deno.ServeOptions, handler: Deno.ServeHandler) { + return Deno.serve({ ...options, onListen: () => void null }, handler) +} + +/** KV storage */ +export class KV { + /** Constructor */ + constructor(path?: string) { + this.ready = Deno.openKv(path).then((kv) => { + this.#kv = kv + return this + }) + } + + /** Is ready ? */ + readonly ready + + /** KV storage */ + #kv = null as null | Deno.Kv + + /** Get key value */ + async get(path: string) { + if (!this.#kv) { + throws("KV storage is not ready") + } + const { value } = await this.#kv.get(path.split(".")) + return (value ?? null) as T + } + + /** Has key */ + async has(path: string) { + const value = await this.get(path) + return (value !== null) && (value !== undefined) + } + + /** Delete key */ + async delete(path: string) { + if (!this.#kv) { + throws("KV storage is not ready") + } + await this.#kv.delete(path.split(".")) + } + + /** Set key value */ + async set(path: string, value: unknown, { ttl = NaN } = {}) { + if (!this.#kv) { + throws("KV storage is not ready") + } + await this.#kv.set(path.split("."), value, { ...(Number.isFinite(ttl) ? { expireIn: ttl } : null) }) + if (Number.isFinite(ttl)) { + setTimeout(() => this.delete(path), ttl + 1) + } + } + + /** Close KV storage */ + close() { + if (!this.#kv) { + throws("KV storage is not ready") + } + this.#kv.close() + } +} diff --git a/source/engine/utils/deno/server_test.ts b/source/engine/utils/deno/server_test.ts new file mode 100644 index 00000000000..ff3d34f2bce --- /dev/null +++ b/source/engine/utils/deno/server_test.ts @@ -0,0 +1,83 @@ +import { dir, expect, MetricsError, t } from "@engine/utils/testing.ts" +import { KV, listen } from "@engine/utils/deno/server.ts" +import { delay } from "std/async/delay.ts" + +const uuid = crypto.randomUUID().slice(-12).toUpperCase() +const test = { + kv: `${dir.test}/io_test_kv.${uuid}.kv`, + value: uuid, + port: 9000, +} +const key = "kv.test" +const permissions = { read: [dir.test], write: [dir.test] } + +Deno.test(t(import.meta, "`KV.has()` returns `true` if key exists"), { permissions }, async () => { + const kv = await new KV(test.kv).ready + await kv.set(`${key}.has`, test.value) + await expect(kv.has(`${key}.has`)).to.be.fulfilled.and.to.eventually.equal(true) + kv.close() +}) + +Deno.test(t(import.meta, "`KV.has()` returns `false` if key doesn't exists"), { permissions }, async () => { + const kv = await new KV(test.kv).ready + await expect(kv.has(`${key}.has.not`)).to.be.fulfilled.and.to.eventually.equal(false) + kv.close() +}) + +Deno.test(t(import.meta, "`KV.get()` returns value if key exists"), { permissions }, async () => { + const kv = await new KV(test.kv).ready + await kv.set(`${key}.get`, test.value) + await expect(kv.get(`${key}.get`)).to.be.fulfilled.and.to.eventually.equal(test.value) + kv.close() +}) + +Deno.test(t(import.meta, "`KV.get()` returns `null` if key doesn't exists"), { permissions }, async () => { + const kv = await new KV(test.kv).ready + await expect(kv.get(`${key}.get.not`)).to.be.fulfilled.and.to.eventually.equal(null) + kv.close() +}) + +Deno.test(t(import.meta, "`KV.set()` can register a key-value"), { permissions }, async () => { + const kv = await new KV(test.kv).ready + await kv.set(`${key}.set`, test.value) + await expect(kv.has(`${key}.set`)).to.be.fulfilled.and.to.eventually.equal(true) + kv.close() +}) + +Deno.test(t(import.meta, "`KV.set()` can register a key-value with expiration"), { permissions }, async () => { + const kv = await new KV(test.kv).ready + await kv.set(`${key}.set.ttl`, test.value, { ttl: 50 }) + await expect(kv.has(`${key}.set.ttl`)).to.be.fulfilled.and.to.eventually.equal(true) + await delay(100) + await expect(kv.has(`${key}.set.ttl`)).to.be.fulfilled.and.to.eventually.equal(false) + kv.close() +}) + +Deno.test(t(import.meta, "`KV.delete()` deletes key-value"), { permissions }, async () => { + const kv = await new KV(test.kv).ready + await kv.set(`${key}.delete`, test.value) + await expect(kv.has(`${key}.delete`)).to.be.fulfilled.and.to.eventually.equal(true) + await kv.delete(`${key}.delete`) + await expect(kv.has(`${key}.delete`)).to.be.fulfilled.and.to.eventually.equal(false) + kv.close() +}) + +Deno.test(t(import.meta, "`KV.close()` closes the kvn"), { permissions }, async () => { + const kv = await new KV(test.kv).ready + expect(() => kv.close()).to.not.throw() +}) + +Deno.test(t(import.meta, "`KV.*()` throws if kv is not ready"), { permissions: "none" }, async () => { + const kv = new KV(`${test.kv}.ready`) + kv.ready.catch(() => null) + await expect(kv.has(key)).to.be.rejectedWith(MetricsError, /not ready/i) + await expect(kv.get(key)).to.be.rejectedWith(MetricsError, /not ready/i) + await expect(kv.set(key, test.value)).to.be.rejectedWith(MetricsError, /not ready/i) + await expect(kv.delete(key)).to.be.rejectedWith(MetricsError, /not ready/i) + await expect(() => kv.close()).to.throw(MetricsError, /not ready/i) +}) + +Deno.test(t(import.meta, "`listen()` returns a listener to a local address"), { permissions: { net: [`0.0.0.0:${test.port}`] } }, async () => { + const server = listen({ port: test.port }, () => new Response(null)) + await expect(server.shutdown()).to.be.fulfilled +}) diff --git a/source/engine/utils/errors.ts b/source/engine/utils/errors.ts new file mode 100644 index 00000000000..d73c47954bc --- /dev/null +++ b/source/engine/utils/errors.ts @@ -0,0 +1,23 @@ +/** Unrecoverable symbol */ +const symbol = Symbol.for("@@unrecoverable") + +/** Metrics error */ +export class MetricsError extends Error { + /** Internal tracker symbol */ + static readonly unrecoverable = symbol + + /** Is unrecoverable error */ + readonly [symbol] = false as boolean + + /** Constructor */ + constructor(message?: string, { unrecoverable = false } = {}) { + super(message) + this.stack = "" + Object.defineProperty(this, symbol, { enumerable: false, value: unrecoverable }) + } +} + +/** Throws an error */ +export function throws(message?: string, options?: ConstructorParameters[1]): never { + throw new MetricsError(message, options) +} diff --git a/source/engine/utils/errors_test.ts b/source/engine/utils/errors_test.ts new file mode 100644 index 00000000000..4e7512331fa --- /dev/null +++ b/source/engine/utils/errors_test.ts @@ -0,0 +1,7 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { MetricsError, throws } from "@engine/utils/errors.ts" + +Deno.test(t(import.meta, "`throws()` throws a `MetricsError`"), { permissions: "none" }, () => { + expect(new MetricsError()).to.be.instanceOf(Error) + expect(() => throws("Expected error")).to.throw(MetricsError, /expected error/i) +}) diff --git a/source/engine/utils/format.ts b/source/engine/utils/format.ts new file mode 100644 index 00000000000..eb603812d65 --- /dev/null +++ b/source/engine/utils/format.ts @@ -0,0 +1,78 @@ +// Imports +import humanformat from "y/human-format@1.2.0?pin=v133" +import pluralize from "y/pluralize@8.0.0?pin=v133" + +/** Formatter */ +export class Formatter { + /** Constructor */ + constructor({ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone } = {}) { + this.timezone = timezone + } + + /** Timezone */ + readonly timezone + + /** Format date */ + date(date: string, options = {} as Intl.DateTimeFormatOptions) { + const intl = new Intl.DateTimeFormat("en-GB", { + timeZone: this.timezone, + day: "day" in options ? options.day : "numeric", + month: "month" in options ? options.month : "short", + year: "year" in options ? options.year : "numeric", + }) + return intl.format(new Date(date)) + } + + /** Format time */ + time(time: string) { + const intl = new Intl.DateTimeFormat("en-GB", { timeZone: this.timezone, hour: "2-digit", minute: "2-digit", second: "2-digit" }) + return intl.format(new Date(time)) + } + + /** Format number */ + number(text: string, number: number, options?: Record): string + number(number: number, options?: Record): string + number() { + let [text, number, options] = arguments + if (typeof text === "number") { + options = number + number = text + text = "" + } + options ??= {} + if (options.format === "bytes") { + options = { scale: "binary", unit: "B", ...options } + } + return `${humanformat(number, options).replace(/(\d)\s/, "$1")} ${pluralize(text, number)}`.trim() + } + + /** Strip emojis from text */ + emojiless(text: string) { + return text.replaceAll(/([\p{Emoji}\u200d]+)/gu, "") + } + + /** Format content in HTML */ + html(content: string) { + return ` + + + Metrics + + + + +
${content}
+ + + + `.trim() + } +} + +/** Default formatter */ +export const Format = new Formatter() diff --git a/source/engine/utils/format_test.ts b/source/engine/utils/format_test.ts new file mode 100644 index 00000000000..45f6d8fc530 --- /dev/null +++ b/source/engine/utils/format_test.ts @@ -0,0 +1,41 @@ +import { Formatter } from "@engine/utils/format.ts" +import { expect, t } from "@engine/utils/testing.ts" +import { DOMParser } from "x/deno_dom@v0.1.38/deno-dom-wasm.ts" + +const format = new Formatter({ timezone: "Europe/Paris" }) + +Deno.test(t(import.meta, "`.date()` formats dates correctly"), { permissions: "none" }, () => { + expect(format.date("2020-01-01T00:00:00Z")).to.equal("1 Jan 2020") + expect(format.date("2020-01-01T00:00:00Z", { year: undefined })).to.equal("1 Jan") + expect(format.date("2020-01-01T00:00:00Z", { day: undefined, month: undefined })).to.equal("2020") +}) + +Deno.test(t(import.meta, "`.time()` formats time correctly"), { permissions: "none" }, () => { + expect(format.time("2020-01-01T00:00:00Z")).to.equal("01:00:00") +}) + +Deno.test(t(import.meta, "`.number()` formats numbers correctly"), { permissions: "none" }, () => { + expect(format.number(1)).to.equal("1") + expect(format.number(1000)).to.equal("1k") + expect(format.number(1024 ** 3, { format: "bytes" })).to.equal("1GiB") +}) + +Deno.test(t(import.meta, "`.number()` pluralize text correctly"), { permissions: "none" }, () => { + expect(format.number("cat", 1)).to.equal("1 cat") + expect(format.number("cat", 1000)).to.equal("1k cats") + expect(format.number("canary", 1)).to.equal("1 canary") + expect(format.number("canary", 1000)).to.equal("1k canaries") +}) + +Deno.test(t(import.meta, "`.emojiless()` strips emojis from text"), { permissions: "none" }, () => { + expect(format.emojiless("hello world !")).to.equal("hello world !") + expect(format.emojiless("hello👋 world🌍 !")).to.equal("hello world !") +}) + +Deno.test(t(import.meta, "`.html()` wraps content in a valid html document"), { permissions: "none" }, () => { + const content = "

Hello world

" + const result = format.html(content) + const document = () => new DOMParser().parseFromString(result, "text/html")! + expect(document).to.not.throw() + expect(document().querySelector("main")!.innerHTML).to.equal(content) +}) diff --git a/source/engine/utils/github.ts b/source/engine/utils/github.ts new file mode 100644 index 00000000000..5850e7040f6 --- /dev/null +++ b/source/engine/utils/github.ts @@ -0,0 +1,70 @@ +//Imports +import { throws } from "@engine/utils/errors.ts" +import { globToRegExp } from "std/path/glob.ts" + +/** Regexs (nb: don't be overly restrictive as GitHub will perform the validation anyways)*/ +const regex = { + account: /^(?[^/]+)/, + repository: /^(?[^/]+)\/(?[^/]+)$/, +} as const + +/** Common ignored patterns */ +export const ignored = { + users: [ + "!*\\[bot\\]", + "!actions-user", + "!action@github.com", + ], + repositories: [], +} + +/** Reactions */ +export const reactions = { + rest: { + heart: "❤ī¸", + "+1": "👍", + "-1": "👎", + laugh: "😄", + confused: "😕", + eyes: "👀", + rocket: "🚀", + hooray: "🎉", + }, +} + +/** Parse handle */ +export function parseHandle(handle: string, options: { entity: "user" | "organization" }): { login: string } +export function parseHandle(handle: string, options: { entity: "repository" }): { owner: string; name: string } +export function parseHandle(handle: string | null | void, options: { entity: string }): { login: string } +export function parseHandle(handle: string | null | void, { entity }: { entity: string }) { + if (typeof handle !== "string") { + throws("No handle provided", { unrecoverable: true }) + } + handle = handle.replace(/[\n\r]/g, "") + switch (entity) { + case "repository": { + const { owner, name } = regex.repository.exec(handle)?.groups ?? throws(`Invalid ${entity} handle: ${handle}`, { unrecoverable: true }) + return { owner, name } + } + case "user": + case "organization": { + const { login } = regex.account.exec(handle)?.groups ?? throws(`Invalid ${entity} handle: ${handle}`, { unrecoverable: true }) + return { login } + } + default: + throws(`Invalid entity: ${entity}`, { unrecoverable: true }) + } +} + +/** Match patterns */ +export function matchPatterns(patterns: string | string[], value: unknown) { + let match = false + for (const pattern of [patterns].flat(Infinity) as string[]) { + const negate = pattern.startsWith("!") + const regex = globToRegExp(pattern.replace(/^!/, ""), { extended: true, globstar: true, caseInsensitive: true, os: "linux" }) + if (regex.test(`${value}`)) { + match = !negate + } + } + return match +} diff --git a/source/engine/utils/github_test.ts b/source/engine/utils/github_test.ts new file mode 100644 index 00000000000..5b0f8f6b23f --- /dev/null +++ b/source/engine/utils/github_test.ts @@ -0,0 +1,49 @@ +import { expect, MetricsError, t } from "@engine/utils/testing.ts" +import { matchPatterns, parseHandle } from "@engine/utils/github.ts" + +Deno.test(t(import.meta, "`parseHandle()` can parse accounts handles"), { permissions: "none" }, () => { + for (const { handle, entity } of [{ handle: "octocat", entity: "user" }, { handle: "github", entity: "organization" }]) { + const login = handle + expect(parseHandle(handle, { entity })).to.deep.equal({ login }) + expect(parseHandle(`${handle}/repository`, { entity })).to.deep.equal({ login }) + expect(() => parseHandle("", { entity })).to.throw(MetricsError, /invalid .* handle/i) + } +}) + +Deno.test(t(import.meta, "`parseHandle()` can parse repositories handles"), { permissions: "none" }, () => { + const { owner, name } = { owner: "octocat", name: "repository" } + const entity = "repository" + const handle = `${owner}/${name}` + expect(parseHandle(handle, { entity })).to.deep.equal({ owner, name }) + expect(() => parseHandle("", { entity })).to.throw(MetricsError, /invalid .* handle/i) + expect(() => parseHandle(owner, { entity })).to.throw(MetricsError, /invalid .* handle/i) +}) + +Deno.test(t(import.meta, "`parseHandle()` throws errors on invalid inputs"), { permissions: "none" }, () => { + for (const arg of [undefined, null]) { + expect(() => parseHandle(arg, { entity: "user" })).to.throw(MetricsError, /no handle provided/i) + } + expect(() => parseHandle("octocat", { entity: "unknown" })).to.throw(MetricsError, /invalid entity/i) +}) + +Deno.test(t(import.meta, "`matchPatterns()` returns a matching result for accounts handles"), { permissions: "none" }, () => { + expect(matchPatterns("*", "octocat")).to.equal(true) + expect(matchPatterns("*/*", "octocat")).to.equal(false) + expect(matchPatterns("octo*", "octocat")).to.equal(true) + expect(matchPatterns("github", "octocat")).to.equal(false) + expect(matchPatterns(["octo*", "!*squid"], "octocat")).to.equal(true) + expect(matchPatterns(["octo*", "!*squid"], "octosquid")).to.equal(false) + expect(matchPatterns(["!octo*", "octocat"], "octocat")).to.equal(true) + expect(matchPatterns(["!octo*", "octocat"], "octosquid")).to.equal(false) +}) + +Deno.test(t(import.meta, "`matchPatterns()` returns a matching result for repositories handles"), { permissions: "none" }, () => { + expect(matchPatterns("*", "octocat/repository")).to.equal(false) + expect(matchPatterns("*/*", "octocat/repository")).to.equal(true) + expect(matchPatterns("octocat/*", "octocat/repository")).to.equal(true) + expect(matchPatterns("github/*", "octocat/repository")).to.equal(false) + expect(matchPatterns(["octocat/*", "!octocat/fork"], "octocat/repository")).to.equal(true) + expect(matchPatterns(["octocat/*", "!octocat/fork"], "octocat/fork")).to.equal(false) + expect(matchPatterns(["!octocat/*", "octocat/repository"], "octocat/repository")).to.equal(true) + expect(matchPatterns(["!octocat/*", "octocat/repository"], "octocat/fork")).to.equal(false) +}) diff --git a/source/engine/utils/graph.ts b/source/engine/utils/graph.ts new file mode 100644 index 00000000000..8cf3b24e2ac --- /dev/null +++ b/source/engine/utils/graph.ts @@ -0,0 +1,440 @@ +// Imports +import * as d3 from "y/d3@7.8.5?pin=v133" +import { DOMParser } from "y/linkedom@0.16.4?pin=v133" + +/** Graph */ +export class Graph { + /** Create a D3 SVG */ + private static svg({ width, height }: { width: number; height: number }) { + const body = d3.select(new DOMParser().parseFromString(``, "text/html")!.body) + const svg = body.append("svg") + .attr("xmlns", "http://www.w3.org/2000/svg") + .attr("width", `${width}`) + .attr("height", `${height}`) + .attr("class", "graph") + return svg + } + + /** Configuration */ + static readonly config = { + width: 480, + height: 315, + title: { + color: "var(--color-title)", + fontsize: 14, + }, + axis: { + color: "rgba(127, 127, 127, .8)", + fontsize: 12, + opacity: 0.5, + }, + legend: { + width: 80, + rect: [20, 10], + fontsize: 12, + margin: 2, + }, + lines: { + width: 2, + }, + areas: { + opacity: 0.1, + }, + points: { + radius: 2, + }, + texts: { + fontsize: 10, + }, + margin: { top: 10, left: 10, right: 10, bottom: 45 }, + } + + /** Generate a random color for a given seed */ + private static color(seed: string) { + let hex = 9 + for (let i = 0; i < seed.length;) { + hex = Math.imul(hex ^ seed.charCodeAt(i++), 9 ** 9) + } + hex = hex ^ hex >>> 9 + const r = (hex & 0xff0000) >> 8 * 2 + const g = (hex & 0x00ff00) >> 8 * 1 + const b = (hex & 0x0000ff) >> 8 * 0 + return `rgb(${r}, ${g}, ${b})` + } + + /** Create a time graph */ + static time(datalist: datalist, options?: options) { + return this.graph("time", datalist, options) + } + + /** Create a line graph */ + static line(datalist: datalist, options?: options) { + return this.graph("line", datalist, options) + } + + /** Create a generic graph */ + private static graph(type: string, datalist: datalist, { width = this.config.width, height = this.config.height, ...options }: options = {}) { + // Create SVG + const margin = this.config.margin + const svg = this.svg({ width, height }) + + // Prepare data + const V = Object.values(datalist).flatMap(({ data }) => data.map(({ x, y }) => ({ x, y }))) + const start = Math.min(...V.map(({ x }) => Number(x))) + const end = Math.max(...V.map(({ x }) => Number(x))) + const extremum = Math.max(...V.map(({ y }) => y)) + const max = !Number.isNaN(Number(options.max)) ? options.max! : extremum + const min = !Number.isNaN(Number(options.min)) ? options.min! : 0 + + // X axis + const x = ((type === "time" ? d3.scaleTime().domain([new Date(start), new Date(end)]) : d3.scaleLinear().domain([start, end])) as ReturnType>) + .range([margin.top, width - margin.left - margin.right]) + let ticks = d3.axisBottom(x) + if (options.ticks) { + ticks = ticks.ticks(options.ticks) + } + if (options.labels) { + ticks = ticks.tickFormat((_, i) => `${options.labels?.[i] ?? ""}`) + } + // @ts-ignore: type inference is too deep + svg.append("g") + .attr("transform", `translate(${margin.left},${height - margin.bottom})`) + .call(ticks) + .call((g: d3select) => g.select(".domain").attr("stroke", this.config.axis.color)) + .call((g: d3select) => g.selectAll(".tick line").attr("stroke-opacity", this.config.axis.opacity)) + .selectAll("text") + .attr("transform", "translate(-5,5) rotate(-45)") + .style("text-anchor", "end") + .style("font-size", `${this.config.axis.fontsize}px`) + .attr("fill", this.config.axis.color) + + // Y axis + const y = d3.scaleLinear() + .domain([max, min]) + .range([margin.left, height - margin.top - margin.bottom]) + // @ts-ignore: type inference is too deep + svg.append("g") + .attr("transform", `translate(${margin.left},${margin.top})`) + .call(d3.axisRight(y).ticks(Math.round(height / 50)).tickSize(width - margin.left - margin.right)) + .call((g: d3select) => g.select(".domain").remove()) + .call((g: d3select) => g.selectAll(".tick line").attr("stroke-opacity", this.config.axis.opacity).attr("stroke-dasharray", "2,2")) + .call((g: d3select) => g.selectAll(".tick text").attr("x", 0).attr("dy", -4)) + .selectAll("text") + .style("font-size", `${this.config.axis.fontsize}px`) + .attr("fill", this.config.axis.color) + + // Title + if (options.title) { + svg.append("text") + .attr("class", "title") + .attr("x", width / 2) + .attr("y", this.config.title.fontsize) + .attr("text-anchor", "middle") + .attr("font-family", "sans-serif") + .attr("stroke", "rgba(88, 166, 255, .05)") + .attr("stroke-linejoin", "round") + .attr("stroke-width", 4) + .attr("paint-order", "stroke fill") + .style("font-size", `${this.config.title.fontsize}px`) + .attr("fill", this.config.title.color) + .text(options.title) + } + + // Legend + if (options.legend) { + const config = this.config + svg.append("g") + .attr("class", "legend") + .attr("transform", `translate(${width - margin.right - config.legend.width},${margin.top})`) + .selectAll("g") + .data(Object.entries(datalist).map(([name, { color = this.color(name) }]) => ({ name, color }))) + .enter() + .each(function (this: d3arg, d: d3data, i: number) { + d3.select(this) + .append("rect") + .attr("x", 0) + .attr("y", i * (config.legend.fontsize + config.legend.margin) + (config.legend.fontsize - config.legend.rect[1]) / 2) + .attr("width", config.legend.rect[0]) + .attr("height", config.legend.rect[1]) + .attr("stroke", d.color) + .attr("fill", d.color) + .attr("fill-opacity", config.areas.opacity) + d3.select(this) + .append("text") + .attr("x", config.legend.rect[0] + 5) + .attr("y", i * (config.legend.fontsize + config.legend.margin)) + .attr("text-anchor", "start") + .attr("dominant-baseline", "hanging") + .attr("fill", d.color) + .style("font-size", `${config.legend.fontsize}px`) + .text(d.name) + }) + } + + // Render series + for (const [name, { color = this.color(name), lines = true, areas = true, points = true, texts = true, data }] of Object.entries(datalist)) { + const X = data.map(({ x }) => x) + const Y = data.map(({ y }) => y) + const T = data.map(({ text }, i) => text ?? Y[i]) + const D = Y.map((y, i) => [X[i], y]) + const DT = Y.map((y, i) => [X[i], y, T[i]]) + + // Graph lines + if (lines) { + svg.append("path") + .datum(D) + .attr("transform", `translate(${margin.left},${margin.top})`) + .attr("d", d3.line().curve(d3.curveLinear).x((d) => x(d[0])).y((d) => y(d[1])) as d3arg) + .attr("fill", "transparent") + .attr("stroke", color) + .attr("stroke-width", this.config.lines.width) + } + + // Graph areas + if (areas) { + svg.append("path") + .datum(D) + .attr("transform", `translate(${margin.left},${margin.top})`) + .attr("d", d3.area().curve(d3.curveLinear).x((d) => x(d[0])).y0((d) => y(d[1])).y1(() => y(min)) as d3arg) + .attr("fill", color) + .attr("fill-opacity", this.config.areas.opacity) + } + + // Graph points + if (points) { + svg.append("g") + .selectAll("circle") + .data(DT) + .join("circle") + .attr("transform", `translate(${margin.left},${margin.top})`) + .attr("cx", (d: d3data) => x(+d[0])) + .attr("cy", (d: d3data) => y(+d[1])) + .attr("r", this.config.points.radius) + .attr("fill", color) + } + + // Graph texts + if (texts) { + svg.append("g") + .attr("fill", "currentColor") + .attr("text-anchor", "middle") + .attr("font-family", "sans-serif") + .attr("font-size", `${this.config.texts.fontsize}px`) + .attr("stroke", "rgba(88, 166, 255, .05)") + .attr("stroke-linejoin", "round") + .attr("stroke-width", 4) + .attr("paint-order", "stroke fill") + .selectAll("text") + .data(DT) + .join("text") + .attr("transform", `translate(${margin.left},${margin.top - 4})`) + .attr("x", (d: d3data) => x(+d[0])) + .attr("y", (d: d3data) => y(+Number(d[1]))) + .text((d: d3data) => d[2]) + .attr("fill", this.config.axis.color) + } + } + + // SVG render + return svg.node()!.outerHTML + } + + /** Create a pie graph */ + static pie( + datalist: Record, + { width = this.config.width, height = this.config.height, ...options }: Pick = {}, + ) { + //Generate SVG + const margin = this.config.margin + const radius = Math.min(width, height) / 2 + const svg = this.svg({ width, height }) + + // Prepare data + const K = Object.keys(datalist) + const V = Object.values(datalist) + const I = d3.range(K.length).filter((i) => !Number.isNaN(V[i].data)) + const D = d3.pie().padAngle(1 / radius).sort(null).value((i) => V[+i].data)(I) + const labels = d3.arc().innerRadius(radius / 2).outerRadius(radius / 2) + + // Graph areas + svg.append("g") + .attr("transform", `translate(${(width - (options.legend ? this.config.legend.width : 0)) / 2},${height / 2})`) + .attr("stroke", "white") + .attr("stroke-width", 1) + .attr("stroke-linejoin", "round") + .selectAll("path") + .data(D) + .join("path") + .attr("fill", (d: d3data) => V[+d.data].color ?? this.color(K[+d.data])) + .attr("d", d3.arc().innerRadius(0).outerRadius(radius) as d3arg) + .append("title") + .text((d: d3data) => `${K[+d.data]}\n${V[+d.data].data}`) + + // Graph texts + svg.append("g") + .attr("transform", `translate(${(width - (options.legend ? this.config.legend.width : 0)) / 2},${height / 2})`) + .attr("font-family", "sans-serif") + .attr("font-size", `${this.config.texts.fontsize}px`) + .attr("text-anchor", "middle") + .attr("fill", "white") + .attr("stroke", "rbga(0,0,0,.9)") + .attr("paint-order", "stroke fill") + .selectAll("text") + .data(D) + .join("text") + .attr("transform", (d: d3data) => `translate(${labels.centroid(d)})`) + .selectAll("tspan") + .data((d: d3data) => { + const lines = `${K[+d.data]}\n${V[+d.data].data}`.split(/\n/) + return (d.endAngle - d.startAngle) > 0.25 ? lines : lines.slice(0, 1) + }) + .join("tspan") + .attr("x", 0) + .attr("y", (_: unknown, i: number) => `${i * 1.1}em`) + .attr("font-weight", (_: unknown, i: number) => i ? null : "bold") + .text((d: d3data) => d) + + // Legend + if (options.legend) { + const config = this.config.legend + svg.append("g") + .attr("class", "legend") + .attr("transform", `translate(${width - margin.right - config.width},${margin.top})`) + .selectAll("g") + .data(Object.keys(datalist).map(([name]) => ({ name, value: datalist[name].data, color: datalist[name].color ?? this.color(name) }))) + .enter() + .each(function (this: d3arg, d: d3data, i: number) { + d3.select(this) + .append("rect") + .attr("x", 0) + .attr("y", i * (config.fontsize + config.margin) + (config.fontsize - config.rect[1]) / 2) + .attr("width", config.rect[0]) + .attr("height", config.rect[1]) + .attr("fill", d.color) + d3.select(this) + .append("text") + .attr("x", config.rect[0] + 5) + .attr("y", i * (config.fontsize + config.margin)) + .attr("text-anchor", "start") + .attr("dominant-baseline", "hanging") + .attr("fill", d.color) + .style("font-size", `${config.fontsize}px`) + .text(`${d.name} (${d.value})`) + }) + } + + // SVG render + return svg.node()!.outerHTML + } + + /** Create a diff graph */ + static diff( + datalist: Record, + { title = "", opacity = this.config.areas.opacity, width = this.config.width, height = this.config.height } = {}, + ) { + // Create SVG + const margin = 5, offset = 34 + const svg = this.svg({ width, height }) + + // Prepare data + const K = Object.keys(datalist) + const V = Object.values(datalist).flatMap(({ data }) => data) + const start = new Date(Math.min(...V.map(({ date }) => Number(date)))) + const end = new Date(Math.max(...V.map(({ date }) => Number(date)))) + const extremum = Math.max(...V.flatMap(({ added, deleted, changed }) => [added + changed, deleted + changed])) + + // X axis + const x = d3.scaleTime() + .domain([start, end]) + .range([margin + offset, width - (offset + margin)]) + svg.append("g") + .attr("transform", `translate(0,${height - (offset + margin)})`) + .call(d3.axisBottom(x)) + .selectAll("text") + .attr("transform", "translate(-5,5) rotate(-45)") + .style("text-anchor", "end") + .style("font-size", `${this.config.axis.fontsize}px`) + + // Y axis + const y = d3.scaleLinear() + .domain([extremum, -extremum]) + .range([margin, height - (offset + margin)]) + svg.append("g") + .attr("transform", `translate(${margin + offset},0)`) + .call(d3.axisLeft(y).tickFormat(d3.format(".2s"))) + .selectAll("text") + .style("font-size", `${this.config.axis.fontsize}px`) + + // Graph areas + for (const { type, sign, fill } of [{ type: "added", sign: +1, fill: "var(--diff-addition)" }, { type: "deleted", sign: -1, fill: "var(--diff-deletion)" }] as const) { + const values = Object.entries(datalist).flatMap(([name, { data }]) => data.flatMap(({ date, ...diff }) => ({ date, [name]: sign * (diff[type] + diff.changed) }))) + svg + .append("g") + .selectAll("g") + .data(d3.stack().keys(K)(values as d3arg)) + .join("path") + .attr("d", d3.area().x((d) => x((d as d3arg).data.date)).y0((d) => y(d[0] || 0)).y1((d) => y(d[1] || 0)) as d3arg) + .attr("fill", fill) + .attr("fill-opacity", opacity) + } + + // Title + if (title) { + svg.append("text") + .attr("class", "title") + .attr("x", width / 2) + .attr("y", this.config.title.fontsize) + .attr("text-anchor", "middle") + .attr("font-family", "sans-serif") + .attr("stroke", "rgba(88, 166, 255, .05)") + .attr("stroke-linejoin", "round") + .attr("stroke-width", 4) + .attr("paint-order", "stroke fill") + .style("font-size", `${this.config.title.fontsize}px`) + .attr("fill", this.config.title.color) + .text(title) + } + + // SVG render + return svg.node()!.outerHTML + } +} + +/** D3 argument */ +// deno-lint-ignore no-explicit-any +type d3arg = any + +/** D3 data */ +type d3data = d3arg + +/** D3 selection */ +type d3select = ReturnType + +/** Datalist */ +type datalist = Record[] + lines?: boolean + areas?: boolean + points?: boolean + texts?: boolean +}> + +/** Data */ +type data = { + x: number | Date + y: T + text?: string +} + +/** Graph options */ +type options = { + width?: number + height?: number + max?: number + min?: number + labels?: Record + ticks?: number + legend?: boolean + title?: string +} diff --git a/source/engine/utils/graph_test.ts b/source/engine/utils/graph_test.ts new file mode 100644 index 00000000000..443c1adacf8 --- /dev/null +++ b/source/engine/utils/graph_test.ts @@ -0,0 +1,56 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { Graph } from "@engine/utils/graph.ts" + +const svg = /^[\s\S]+<\/svg>$/ + +Deno.test(t(import.meta, "`.line()` returns a graph"), { permissions: "none" }, () => { + expect(Graph.line({ + A: { + data: [{ x: 0, y: 1 }, { x: 1, y: 2 }, { x: 2, y: 1 }, { x: 3, y: 4 }], + }, + B: { + data: [{ x: 0.5, y: 1.5, text: "B0" }, { x: 2.5, y: 2, text: "B1" }], + }, + })).to.match(svg) + expect(Graph.line({ + A: { + data: [{ x: 0, y: 1 }, { x: 1, y: 2 }, { x: 2, y: 1 }, { x: 3, y: 4 }], + }, + B: { + data: [{ x: 0.5, y: 1.5, text: "B0" }, { x: 2.5, y: 2, text: "B1" }], + }, + }, { min: 0, max: 5, ticks: 4, labels: { 0: "0" }, legend: true, title: "title" })).to.match(svg) +}) + +Deno.test(t(import.meta, "`.time()` returns a graph"), { permissions: "none" }, () => { + expect(Graph.time({ + A: { + data: [{ x: new Date("2000"), y: 1 }, { x: new Date("2010"), y: 2 }, { x: new Date("2020"), y: 1 }, { x: new Date("2030"), y: 4 }], + }, + B: { + data: [{ x: new Date("2005"), y: 1.5, text: "B0" }, { x: new Date("2025"), y: 2, text: "B1" }], + }, + })).to.match(svg) +}) + +Deno.test(t(import.meta, "`.pie()` returns a graph"), { permissions: "none" }, () => { + expect(Graph.pie({ + A: { data: 0.6 }, + B: { data: 0.2 }, + C: { data: 0.1 }, + }, { legend: true })).to.match(svg) + expect(Graph.pie({ + A: { data: 0.6 }, + B: { data: 0.2 }, + C: { data: 0.9999 }, + D: { data: 0.0001 }, + }, { legend: false })).to.match(svg) +}) + +Deno.test(t(import.meta, "`.diff()` returns a graph"), { permissions: "none" }, () => { + expect(Graph.diff({ + A: { data: [{ date: new Date("2019"), added: 100, deleted: 20, changed: 12 }] }, + B: { data: [{ date: new Date("2020"), added: 40, deleted: 23, changed: 20 }, { date: new Date("2021"), added: 54, deleted: 12, changed: 30 }] }, + C: { data: [{ date: new Date("2021"), added: 0, deleted: 30, changed: 10 }] }, + })).to.match(svg) +}) diff --git a/source/engine/utils/inspect.ts b/source/engine/utils/inspect.ts new file mode 100644 index 00000000000..64391abad6e --- /dev/null +++ b/source/engine/utils/inspect.ts @@ -0,0 +1,18 @@ +// Imports +import { throws } from "@engine/utils/errors.ts" + +/** Inspect */ +export function inspect(message: unknown, { json = false } = {}) { + try { + if (json) { + throws() + } + return Deno.inspect(message, { colors: true, depth: Infinity, iterableLimit: 16, strAbbreviateSize: 120 }) + } catch { + try { + return JSON.stringify(message, null, 2) + } catch { + return `${message}` + } + } +} diff --git a/source/engine/utils/inspect_test.ts b/source/engine/utils/inspect_test.ts new file mode 100644 index 00000000000..6ffe4673b45 --- /dev/null +++ b/source/engine/utils/inspect_test.ts @@ -0,0 +1,18 @@ +import { dir, expect, t } from "@engine/utils/testing.ts" +import { inspect } from "@engine/utils/inspect.ts" + +Deno.test(t(import.meta, "`inspect()` returns a representation of the value"), { permissions: { read: [dir.source] } }, () => { + const object = { foo: "bar" } + expect(inspect(object)).to.be.a("string").and.not.equal(`${object}`) +}) + +Deno.test( + t(import.meta, "`inspect()` is polyfilled in non-deno environments"), + { permissions: "none" }, + () => { + const object = { foo: "bar" } + expect(inspect(object, { json: true })).to.include("foo").and.to.include("bar") + expect(inspect(object, { json: true })).to.not.equal(`${object}`) + expect(inspect(0n, { json: true })).to.equal("0") + }, +) diff --git a/source/engine/utils/language.ts b/source/engine/utils/language.ts new file mode 100644 index 00000000000..c0530bb6f93 --- /dev/null +++ b/source/engine/utils/language.ts @@ -0,0 +1,94 @@ +// Imports +import hljs from "y/highlight.js@11.8.0/lib/core?pin=v133" +import hljsDiff from "y/highlight.js@11.8.0/lib/languages/diff?pin=v133" +import hljsMarkdown from "y/highlight.js@11.8.0/lib/languages/markdown?pin=v133" +import hljsTypeScript from "y/highlight.js@11.8.0/lib/languages/typescript?pin=v133" +import hljsXML from "y/highlight.js@11.8.0/lib/languages/xml?pin=v133" +import hljsYAML from "y/highlight.js@11.8.0/lib/languages/yaml?pin=v133" +import * as YAML from "std/yaml/parse.ts" +hljs.registerLanguage("diff", hljsDiff) +hljs.registerLanguage("markdown", hljsMarkdown) +hljs.registerLanguage("typescript", hljsTypeScript) +hljs.registerLanguage("xml", hljsXML) +hljs.registerAliases(["html", "ejs"], { languageName: "xml" }) +hljs.registerLanguage("yaml", hljsYAML) + +/** Language cache */ +export const cache = await load("languages.yml").catch(() => ({})) as Record + +/** Language guesser */ +// TODO(@lowlighter): to implement +// deno-lint-ignore require-await +export async function language(_filename: string, content: string, { gitattributes = "" } = {}) { + /* + load("vendor.yml") + load("documentation.yml") + */ + // deno-lint-ignore no-explicit-any + const result = {} as any + + // Process gitattributes override + if (gitattributes) { + result.from = "gitattributes" + } + + // Process interpreter + if (content.startsWith("#!/")) { + const _line = content.split("\n", 1)[0] + result.from = "shebang" + } + + // Search for specific filename + // deno-lint-ignore no-constant-condition + if (false) { + result.from = "filename" + } + + // Search for specific extension + // deno-lint-ignore no-constant-condition + if (false) { + result.from = "extension" + } + + return { + language: null, + type: "unknown", + bytes: 0, + color: "#959da5", + } +} + +/** Highlight code */ +export const highlight = Object.assign(function (language: string, code: string) { + if (hljs.getLanguage(language)) { + code = hljs.highlight(code, { language, ignoreIllegals: true }).value + } + return { language, code } +}, { + /** List of registered languages */ + languages: hljs.listLanguages, + /** Resolve language name and load highlighting syntax if required */ + async resolve(language: string) { + for (const [key, { ace_mode: mode, extensions = [] }] of Object.entries(cache)) { + const name = key.toLocaleLowerCase() + if ((name === language.toLocaleLowerCase()) || (extensions.find((extension) => extension === `.${language}`))) { + language = mode + break + } + } + if (!this.languages().includes(language)) { + try { + const module = await import(`y/highlight.js@11.8.0/lib/languages/${language}?pin=v133`) + hljs.registerLanguage(language, module.default) + } catch { + // Ignore + } + } + return language + }, +}) + +/** Load file from GitHub linguist */ +async function load(file: string) { + return YAML.parse(await fetch(`https://raw.githubusercontent.com/github-linguist/linguist/master/lib/linguist/${file}`).then((response) => response.text())) +} diff --git a/source/engine/utils/language_test.ts b/source/engine/utils/language_test.ts new file mode 100644 index 00000000000..be37612a28f --- /dev/null +++ b/source/engine/utils/language_test.ts @@ -0,0 +1,23 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { highlight, language } from "@engine/utils/language.ts" + +const permissions = { net: ["raw.githubusercontent.com/github-linguist/linguist/master/lib/linguist/languages.yml"] } + +Deno.test.ignore(t(import.meta, "`language()` can resolve language name"), { permissions: "none" }, async () => { + await expect(language("mod.ts", "const foo = 'bar'")).to.eventually.containSubset({ language: "TypeScript", type: "programming" }) +}) + +Deno.test(t(import.meta, "`highlight.resolve()` can resolve language name and autoload syntax highlighting"), { permissions }, async () => { + await expect(highlight.languages()).to.not.include("javascript") + await expect(highlight.resolve("js")).to.eventually.equal("javascript") + await expect(highlight.languages()).to.include("javascript") +}) + +Deno.test(t(import.meta, "`highlight()` can highlight code"), { permissions }, async () => { + const { code } = highlight("ts", "const foo = 'bar'") + await expect(code).to.include("hljs-string") +}) + +Deno.test(t(import.meta, "`highlight()` does not throw on unknown languages"), { permissions }, async () => { + await expect(highlight("đŸĻ•", "code")).to.containSubset({ language: "đŸĻ•", code: "code" }) +}) diff --git a/source/engine/utils/log.ts b/source/engine/utils/log.ts new file mode 100644 index 00000000000..5131bdc62cf --- /dev/null +++ b/source/engine/utils/log.ts @@ -0,0 +1,203 @@ +//Imports +import { inspect } from "@engine/utils/inspect.ts" + +/** Logger */ +export class Logger { + /** Identifier */ + readonly id + + /** Tags */ + readonly tags + + /** IO */ + private readonly stdio + + /** Constructor */ + constructor(meta: { url: string }, { level = "none" as "none" | channel | number, tags = {} as Record, stdio = console as stdio } = {}) { + this.id = meta.url.replace(Logger.root, "").replace(".ts", "").replace("/mod", "") + this.tags = tags + this.stdio = stdio + this.setLevel(level) + } + + /** Log level */ + setLevel(level: "none" | channel | number) { + switch (true) { + case (level === "none"): + Object.assign(this, { level: -Infinity }) + return + case (typeof level === "number") && (Object.values(Logger.channels).includes(level)): + Object.assign(this, { level }) + return + case (level in Logger.channels): + Object.assign(this, { level: Logger.channels[level as channel] }) + return + } + } + + /** Error */ + error(message: unknown) { + if (this.level >= Logger.channels.error) this.print("error", message) + } + + /** Warn */ + warn(message: unknown) { + if (this.level >= Logger.channels.warn) this.print("warn", message) + } + + /** Success */ + success(message: unknown) { + if (this.level >= Logger.channels.success) this.print("success", message) + } + + /** Info */ + info(message: unknown) { + if (this.level >= Logger.channels.info) this.print("info", message) + } + + /** Message */ + message(message: unknown) { + if (this.level >= Logger.channels.message) this.print("message", message) + } + + /** IO operations */ + io(message: unknown) { + if (this.level >= Logger.channels.io) this.print("io", message) + } + + /** Debug */ + debug(message: unknown) { + if (this.level >= Logger.channels.debug) this.print("debug", message) + } + + /** Trace */ + trace(message: unknown) { + if (this.level >= Logger.channels.trace) this.print("trace", message) + } + + /** Development message (bypass log level) */ + probe(message: unknown) { + this.print("probe", message) + } + + /** Raw message (bypass log level) */ + raw(...message: Parameters) { + if (Logger.raw) { + this.stdio.log(...message) + } + } + + /** Create a new logger with tags */ + with(tags: Record) { + const logger = new Logger({ url: "" }) + Object.assign(logger, { id: this.id, level: this.level, tags: { ...this.tags, ...tags }, stdio: this.stdio }) + return logger + } + + /** Print message */ + private print(channel: channel, content: unknown) { + switch (channel) { + case "error": + return this.stdio.error(...this.format(channel, content)) + case "warn": + return this.stdio.warn(...this.format(channel, content)) + case "success": + case "info": + return this.stdio.info(...this.format(channel, content)) + case "message": + return this.stdio.log(...this.format(channel, content)) + case "io": + case "debug": + case "trace": + case "probe": + return this.stdio.debug(...this.format(channel, content)) + } + } + + /** Format */ + private format(channel: channel, message: unknown) { + const color = Logger.colors[channel] + const styles = [ + `%c${Logger.blocks[channel]}%c ${this.id.padEnd(28)}%c`, + `color: ${{ black: "gray" }[color] ?? color}`, + `background-color: ${{ inherit: "white" }[color] ?? color}; color: ${{ gray: "black", black: "gray", inherit: "black" }[color] ?? "white"}`, + "", + ] + if (Object.keys(this.tags).length) { + styles[0] += " " + } + for (const [key, value] of Object.entries(this.tags)) { + styles[0] += `%c ${key} %c ${value} %c` + styles.push("background-color: gray; font-style: italic", "background-color: white; color: gray; font-style: italic", "") + } + if (typeof message === "object") { + styles[0] += `%c ${inspect(message)}` + styles.push("") + } else { + styles[0] += `%c ${message}%c` + styles.push(`color: ${{ black: "gray" }[color] ?? color}`, "") + } + return styles + } + + /** Channels */ + static readonly channels = { + error: 0, + warn: 1, + success: 2, + io: 3, + info: 2, + message: 3, + debug: 4, + trace: 5, + probe: -1, + none: -Infinity, + } + + /** Log level */ + readonly level = Logger.channels.none + + /** Print raw logs */ + static raw = true + + /** Project root path */ + private static readonly root = new URL("../..", import.meta.url).href + + /** Color schemes */ + private static readonly colors = { + error: "red", + warn: "yellow", + success: "green", + io: "blue", + info: "cyan", + message: "inherit", + debug: "gray", + trace: "black", + probe: "magenta", + } + + /** Blocks */ + private static readonly blocks = { + error: "█", + warn: "▓", + success: "▒", + io: "▒", + info: "▒", + message: "░", + debug: "░", + trace: "░", + probe: "█", + } +} + +/** Channel */ +export type channel = Exclude + +/** Stdio */ +type stdio = { + error: (...args: unknown[]) => void + warn: (...args: unknown[]) => void + info: (...args: unknown[]) => void + log: (...args: unknown[]) => void + debug: (...args: unknown[]) => void +} diff --git a/source/engine/utils/log_test.ts b/source/engine/utils/log_test.ts new file mode 100644 index 00000000000..f57aae6ccd7 --- /dev/null +++ b/source/engine/utils/log_test.ts @@ -0,0 +1,82 @@ +import { expect, t, test } from "@engine/utils/testing.ts" +import { channel, Logger } from "@engine/utils/log.ts" + +export class DevNull { + readonly messages = [] as unknown[] + flush() { + this.messages.splice(0) + } + error(message: unknown, ..._: unknown[]) { + this.messages.push(message) + } + warn(message: unknown, ..._: unknown[]) { + this.messages.push(message) + } + info(message: unknown, ..._: unknown[]) { + this.messages.push(message) + } + log(message: unknown, ..._: unknown[]) { + this.messages.push(message) + } + debug(message: unknown, ..._: unknown[]) { + this.messages.push(message) + } +} + +const stdio = new DevNull() +const log = new Logger(import.meta, { level: Logger.channels.none, tags: { foo: "bar" }, stdio }) + +Deno.test(t(import.meta, "is instantiable"), { permissions: "none" }, () => { + expect(log.id).to.include("log_test") + expect(log.level).to.equal(Logger.channels.none) + expect(log.tags).to.deep.equal({ foo: "bar" }) +}) + +Deno.test(t(import.meta, "`.setLevel()` changes log level"), { permissions: "none" }, () => { + for (const level of Object.entries(Logger.channels).flat() as Array<"none" | channel | number>) { + log.setLevel(level) + expect(log.level).to.equal((Logger.channels as test)[level] ?? level) + } +}) + +Deno.test(t(import.meta, "`.raw()` prints log directly"), { permissions: "none" }, () => { + stdio.flush() + log.raw("foo") + expect(stdio.messages).to.have.lengthOf(1) + expect(stdio.messages[0]).and.to.include("foo") +}) + +Deno.test(t(import.meta, "`.with()` returns a new logger with inherited properties"), { permissions: "none" }, () => { + stdio.flush() + log.probe(null) + expect(stdio.messages).to.have.lengthOf(1) + expect(stdio.messages[0]).and.to.include("foo").and.to.include("bar") + + const child = log.with({ baz: "qux" }) + expect(child.tags).to.deep.equal({ foo: "bar", baz: "qux" }) + stdio.flush() + child.probe(null) + expect(stdio.messages).to.have.lengthOf(1) + expect(stdio.messages[0]).and.to.include("foo").and.to.include("bar").and.to.include("baz").and.to.include("qux") +}) + +for (const channel of Object.keys(Logger.channels).filter((channel) => channel !== "none") as channel[]) { + Deno.test(t(import.meta, `\`.${channel}()\` prints logs correctly and honors log level`), { permissions: "none" }, () => { + const stdio = new DevNull() + const log = new Logger(import.meta, { level: Math.max(...Object.values(Logger.channels)), tags: { foo: "bar" }, stdio }) + + stdio.flush() + log[channel](channel) + expect(stdio.messages).to.have.lengthOf(1) + expect(stdio.messages[0]).to.include(channel) + log.setLevel(Logger.channels.none) + + stdio.flush() + log[channel](channel) + if (channel === "probe") { + expect(stdio.messages).to.have.lengthOf(1) + } else { + expect(stdio.messages).to.be.empty + } + }) +} diff --git a/source/engine/utils/markdown.ts b/source/engine/utils/markdown.ts new file mode 100644 index 00000000000..173b39174b6 --- /dev/null +++ b/source/engine/utils/markdown.ts @@ -0,0 +1,118 @@ +// Imports +import rehypeStringify from "y/rehype-stringify@10.0.0?pin=v133" +import remarkParse from "y/remark-parse@11.0.0?pin=v133" +import remarkRehype from "y/remark-rehype@11.0.0?pin=v133" +import remarkGfm from "y/remark-gfm@4.0.0?pin=v133" +import { unified } from "y/unified@11.0.4?pin=v133" +import { highlight } from "@engine/utils/language.ts" +import { DOMParser } from "x/deno_dom@v0.1.38/deno-dom-wasm.ts" +import { Format } from "@engine/utils/format.ts" + +/** Markdown renderer (internal) */ +const remark = unified() + // deno-lint-ignore no-explicit-any + .use(remarkParse as any) + .use(remarkGfm) + .use(remarkRehype) + // deno-lint-ignore no-explicit-any + .use(rehypeStringify as any) + +/** Render markdown */ +export async function markdown(text: string, { sanitize = "svg" as false | "svg" } = {}) { + let { value: render } = await remark.process(text) + + // Sanitize HTML + if (sanitize) { + // Import needs to be dynamic as it's not supported in browsers + const { default: sanitizeHTML } = await import("y/sanitize-html@2.11.0?pin=v133") + switch (sanitize) { + // SVG content + case "svg": { + const allowedTags = [ + // Headers + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + // Text + "p", + "strong", + "em", + "del", + // Blockquotes + "blockquote", + // Code + "pre", + "code", + // Links + "a", + // Images + "img", + // Tables + "table", + "thead", + "tbody", + "tfoot", + "tr", + "th", + "td", + // Lists + "ul", + "ol", + "li", + "input", + // Horizontal rules + "hr", + // Line breaks + "br", + ] + const allowedAttributes = { + "*": ["class"], + input: ["type", "checked", "disabled"], + img: ["src", "alt"], + } + render = sanitizeHTML( + sanitizeHTML(`${render}`, { + allowedTags, + allowedAttributes, + }), + { + allowedTags: ["span", ...allowedTags.filter((tag) => !["input"].includes(tag))], + allowedAttributes, + transformTags: { + h1: sanitizeHTML.simpleTransform("div", { class: "link" }), + h2: sanitizeHTML.simpleTransform("div", { class: "link" }), + h3: sanitizeHTML.simpleTransform("div", { class: "link" }), + h4: sanitizeHTML.simpleTransform("div", { class: "link" }), + h5: sanitizeHTML.simpleTransform("div", { class: "link" }), + h6: sanitizeHTML.simpleTransform("div", { class: "link" }), + a: sanitizeHTML.simpleTransform("span", { class: "link" }), + input(_, input) { + return { + tagName: "span", + attribs: input.type === "checkbox" ? { class: `input checkbox ${"checked" in input ? "checked" : ""}`.trim() } : {} as Record, + } + }, + }, + }, + ) + } + } + } + + // Code highlighting + if (/<\/code>/.test(`${render}`)) { + const document = new DOMParser().parseFromString(Format.html(`${render}`), "text/html")! + await Promise.all([...document.querySelectorAll("code") as unknown as Array<{ className: string; innerHTML: string }>].map(async (code) => { + const language = code.className.replace("language-", "") + if (language) { + code.innerHTML = highlight(await highlight.resolve(language), code.innerHTML).code + } + })) + render = document.querySelector("main")!.innerHTML + } + + return render +} diff --git a/source/engine/utils/markdown_test.ts b/source/engine/utils/markdown_test.ts new file mode 100644 index 00000000000..fa2a26773bb --- /dev/null +++ b/source/engine/utils/markdown_test.ts @@ -0,0 +1,14 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { markdown } from "@engine/utils/markdown.ts" + +Deno.test(t(import.meta, "`markdown()` returns processed html content"), { permissions: "none" }, async () => { + await expect(markdown("**Hello**", { sanitize: "svg" })).to.eventually.equal("

Hello

") +}) + +Deno.test(t(import.meta, "`markdown()` can highlight code blocks"), { permissions: "none" }, async () => { + await expect(markdown("```ts\nconst foo = true\n```", { sanitize: "svg" })).to.eventually.match(/class="language-ts"/).and.to.match(/class="hljs-.*?"/) +}) + +Deno.test(t(import.meta, "`markdown()` can handle task lists"), { permissions: "none" }, async () => { + await expect(markdown("- [ ] A\n- [x] B", { sanitize: "svg" })).to.eventually.match(/class="input checkbox"/).and.to.match(/class="input checkbox checked"/) +}) diff --git a/source/engine/utils/secret.ts b/source/engine/utils/secret.ts new file mode 100644 index 00000000000..efd933fde2a --- /dev/null +++ b/source/engine/utils/secret.ts @@ -0,0 +1,19 @@ +/** Secret, used to prevent leaks in logs by hiding its value */ +export class Secret { + /** Constructor */ + constructor(value: unknown) { + this.#value = `${value ?? ""}` + this.empty = !this.#value.length + } + + /** Is empty */ + readonly empty + + /** Value */ + readonly #value + + /** Read value */ + read() { + return this.#value + } +} diff --git a/source/engine/utils/secret_test.ts b/source/engine/utils/secret_test.ts new file mode 100644 index 00000000000..2604c326efd --- /dev/null +++ b/source/engine/utils/secret_test.ts @@ -0,0 +1,23 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { inspect } from "@engine/utils/inspect.ts" +import { Secret } from "@engine/utils/secret.ts" + +const test = { value: "SECRET_VALUE" } +const secret = new Secret(test.value) + +Deno.test(t(import.meta, "`.read()` returns secret value"), { permissions: "none" }, () => { + expect(secret.read()).to.equal(test.value) +}) + +Deno.test(t(import.meta, "`.#value` is not leaked"), { permissions: "none" }, () => { + expect(inspect(secret)).to.not.include(test.value) +}) + +Deno.test(t(import.meta, "`.empty` is correct"), { permissions: "none" }, () => { + expect(secret.empty).to.equal(false) + { + const secret = new Secret(null) + expect(secret.empty).to.equal(true) + expect(secret.read()).to.equal("") + } +}) diff --git a/source/engine/utils/shuffle.ts b/source/engine/utils/shuffle.ts new file mode 100644 index 00000000000..2702c20940b --- /dev/null +++ b/source/engine/utils/shuffle.ts @@ -0,0 +1,8 @@ +/** Shuffle array */ +export function shuffle(array: T[]) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[array[i], array[j]] = [array[j], array[i]] + } + return array +} diff --git a/source/engine/utils/shuffle_test.ts b/source/engine/utils/shuffle_test.ts new file mode 100644 index 00000000000..df622bb891a --- /dev/null +++ b/source/engine/utils/shuffle_test.ts @@ -0,0 +1,9 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { shuffle } from "@engine/utils/shuffle.ts" + +Deno.test(t(import.meta, "`shuffle()` shuffles the content of array"), { permissions: "none" }, () => { + const array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + const shuffled = shuffle(array) + expect(shuffled).to.not.equal([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + expect(array).to.equal(shuffled) +}) diff --git a/source/engine/utils/testing.ts b/source/engine/utils/testing.ts new file mode 100644 index 00000000000..3b90a6c8d6c --- /dev/null +++ b/source/engine/utils/testing.ts @@ -0,0 +1,51 @@ +/// +// Imports +import { is, parse } from "@engine/utils/validation.ts" +import { Logger } from "@engine/utils/log.ts" +import * as dir from "@engine/paths.ts" +import { toFileUrl } from "std/path/to_file_url.ts" +import { bgBrightBlack, bgYellow, black, brightCyan, cyan } from "std/fmt/colors.ts" +import chai from "y/chai@4.3.10?pin=v133" +import chaiSubset from "y/chai-subset@1.6.0?pin=v133" +import chaiAsPromised from "y/chai-as-promised@7.1.1?pin=v133" + +/** Logger */ +export const log = new Logger(import.meta) + +/** Mock utility (first argument is the expected input, and second a mock function called with parsed input that return mocked output) */ +export function mock(input: T, data: (vars: is.infer>) => unknown) { + return function (vars: Record) { + return data(parse(is.object(input), vars, { sync: true })) + } +} + +/** Test namer */ +export function t(meta: { url: string } | string, test: string | null) { + let [icon, mod] = (typeof meta === "string") ? meta.split(" ") : ["", meta.url.replace(toFileUrl(dir.source).href, "").replace(/_test\.ts$/, "").replace(/^\//, "")] + switch (true) { + case mod.startsWith("engine/utils"): + icon = "🔧" + break + case mod.startsWith("engine/"): + icon = "⚙ī¸" + break + } + const f = (text: string) => text.replace(/`([\s\S]+?)`/g, (_, text) => cyan(text)).replace(/\*([\s\S]+?)\*/g, (_, text) => brightCyan(text)) + return `${bgBrightBlack(`${icon.trim()} ${mod.padEnd(38)}`)} ${test !== null ? f(test) : bgYellow(black(" NO TESTS FOUND ! "))}` +} + +/** Chai assertions */ +chai.config.truncateThreshold = 0 +chai.config.showDiff = true +export const { expect } = chai.use(chaiSubset).use(chaiAsPromised) + +/** Testing type */ +// deno-lint-ignore no-explicit-any +export type test = any + +// Exports +export { MetricsError, throws } from "@engine/utils/errors.ts" +export { is, MetricsValidationError } from "@engine/utils/validation.ts" +export { Status } from "std/http/status.ts" +export { faker } from "y/@faker-js/faker@8.0.2?pin=v133" +export { dir } diff --git a/source/engine/utils/testing_test.ts b/source/engine/utils/testing_test.ts new file mode 100644 index 00000000000..a41e118e55e --- /dev/null +++ b/source/engine/utils/testing_test.ts @@ -0,0 +1,11 @@ +import { expect, is, MetricsValidationError, mock, t } from "@engine/utils/testing.ts" + +Deno.test(t(import.meta, "`t()` warns when no tests are present"), { permissions: "none" }, () => { + expect(t(import.meta, null)).to.match(/NO TESTS FOUND/i) +}) + +Deno.test(t(import.meta, "`mock()` validates inputs and returns mocked data"), { permissions: "none" }, () => { + const mocked = mock({ foo: is.string() }, ({ foo }) => foo) + expect(mocked({ foo: "bar" })).to.equal("bar") + expect(() => mocked({ foo: 1 })).to.throw(MetricsValidationError) +}) diff --git a/source/engine/utils/validation.ts b/source/engine/utils/validation.ts new file mode 100644 index 00000000000..048b2187a1b --- /dev/null +++ b/source/engine/utils/validation.ts @@ -0,0 +1,77 @@ +//Imports +import { z as is } from "x/zod@v3.21.4/mod.ts" +import { zodToJsonSchema } from "y/zod-to-json-schema@3.21.4?pin=v133" +import { fromZodError } from "y/zod-validation-error@1.5.0?pin=v133" +import { MetricsError } from "@engine/utils/errors.ts" + +/** Validator */ +export type Validator = is.ZodObject + +/** Deep partial */ +export type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T + +/** Validation error */ +export class MetricsValidationError extends MetricsError { + constructor(error: is.ZodError) { + super(`${fromZodError(error)}`, { unrecoverable: true }) + } +} + +/** Parse input (sync) */ +function _parse(input: T, vars: unknown) { + const result = input.safeParse(vars) + if (!result.success) { + throw new MetricsValidationError(result.error) + } + return result.data +} +/** Parse input (async) */ +async function _parseAsync(input: T, vars: unknown) { + const result = await input.safeParseAsync(vars) + if (!result.success) { + throw new MetricsValidationError(result.error) + } + return result.data +} + +/** Parse input */ +export function parse(input: T, vars: unknown, options: { sync: true }): is.infer +export function parse(input: T, vars: unknown, options?: { sync?: false }): Promise> +export function parse(input: T, vars: unknown, { sync = false } = {}) { + return sync ? _parse(input, vars) : _parseAsync(input, vars) +} + +/** Schema */ +export function toSchema(validator: Validator) { + const schema = zodToJsonSchema(validator as unknown as Parameters[0], { $refStrategy: "none" }) + + // Transform secrets + transform(schema as schema, (_, value) => (Array.isArray(value.anyOf))&&(value.anyOf.length === 2)&&(!Object.keys(value.anyOf[0]).length)&&(!Object.keys(value.anyOf[1]).length), (_, value) => { + delete value.anyOf + return {...value, type: "string", writeOnly: true} + }) + + return schema +} + +/** Schema type */ +type schema = { anyOf?: schema[], properties?: Record, [key:string]: unknown} + +/** Schema transforms */ +function transform(schema:schema, predicate: (path:string[], value: schema) => boolean, transformer:(path:string[], value: schema) => schema, path = [] as string[]) { + if (predicate(path, schema)) { + Object.assign(schema[path.at(-1)!] ?? schema, transformer(path, schema)) + } + if (Array.isArray(schema.anyOf)) { + schema.anyOf.forEach(value => transform(value, predicate, transformer, path)) + return + } + if (schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + transform(value, predicate, transformer, [...path, key]) + } + } +} + +// Exports +export { is } diff --git a/source/engine/utils/validation_test.ts b/source/engine/utils/validation_test.ts new file mode 100644 index 00000000000..d08ef7b14d3 --- /dev/null +++ b/source/engine/utils/validation_test.ts @@ -0,0 +1,39 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { is, MetricsValidationError, parse, toSchema } from "@engine/utils/validation.ts" +import {secret} from "@engine/config.ts" + +const validator = is.object({ foo: is.string() }) + +Deno.test(t(import.meta, "`.parse()` validates and returns data synchronously"), { permissions: "none" }, () => { + expect(parse(validator, { foo: "bar" }, { sync: true })).to.deep.equal({ foo: "bar" }) + expect(() => parse(validator, { foo: true }, { sync: true })).to.throw(MetricsValidationError) +}) + +Deno.test(t(import.meta, "`.parse()` can validate and returns data asynchronously"), { permissions: "none" }, async () => { + await expect(parse(validator, { foo: "bar" }, { sync: false })).to.be.a("promise").and.to.be.eventually.deep.equal({ foo: "bar" }) + await expect(parse(validator, { foo: true }, { sync: false })).to.be.a("promise").and.to.be.rejectedWith(MetricsValidationError) +}) + +Deno.test(t(import.meta, "`.toSchema()` returns a JSON schema"), { permissions: "none" }, () => { + expect(toSchema(is.object({ foo: is.string() }))).to.have.property("$schema") +}) + +Deno.test(t(import.meta, "`.toSchema()` to transform `Secret` to `{type:'string', writeOnly:true}`"), { permissions: "none" }, () => { + const schema = is.object({ secret, foo: is.string(), nested:is.object({bar:is.string(), secret}), nullable:secret.nullable() }) + const expected = { + properties: { + secret: { type: "string", writeOnly: true }, + foo: { type: "string" }, + nested: { + properties: { + bar: { type: "string" }, + secret: { type: "string", writeOnly: true } + } + }, + nullable: { + anyOf: [ { type: "string", writeOnly: true }, { type: "null" } ] + } + } + } + expect(toSchema(schema)).to.containSubset(expected) +}) diff --git a/source/engine/version.ts b/source/engine/version.ts new file mode 100644 index 00000000000..ee824f96566 --- /dev/null +++ b/source/engine/version.ts @@ -0,0 +1,49 @@ +// Imports +import { read } from "@engine/utils/deno/io.ts" +import * as JSONC from "std/jsonc/parse.ts" +import { basename } from "std/path/basename.ts" +import { cmp } from "std/semver/cmp.ts" +import { parse } from "std/semver/parse.ts" + +/** Version (internal, exported for testing purposes only) */ +export const testing = { + number: "0.0.0", + async parse(config = "deno.jsonc") { + try { + const parsed = JSONC.parse(await read(config)) as { version: string } + parse(parsed.version) + return parsed.version + } catch { + // Ignore + } + return testing.number + }, +} + +/** Version */ +export const version = { + get number() { + return testing.number + }, +} +testing.number = await testing.parse() + +/** Get latest version number */ +export async function latest(url = "https://github.com/lowlighter/metrics/releases/latest") { + try { + let latest = basename(await fetch(url).then((response) => (response.body?.cancel(), response.url))) + try { + parse(latest) + } catch (error) { + if (error instanceof TypeError) { + latest = `${latest}.0` + } + } + if (cmp(parse(latest), ">", parse(version.number))) { + return latest + } + } catch { + // Ignore + } + return version.number +} diff --git a/source/engine/version_test.ts b/source/engine/version_test.ts new file mode 100644 index 00000000000..58785624116 --- /dev/null +++ b/source/engine/version_test.ts @@ -0,0 +1,23 @@ +import { expect, t } from "@engine/utils/testing.ts" +import { latest, testing, version } from "@engine/version.ts" + +Deno.test(t(import.meta, "`version` is correctly parsed"), { permissions: { read: ["deno.jsonc"] } }, async () => { + await expect(testing.parse("deno.jsonc")).to.eventually.equal(version.number) + await expect(testing.parse("")).to.eventually.equal(version.number) +}) + +Deno.test(t(import.meta, "`latest()` returns latest release"), { permissions: { net: ["example.com/releases/"] } }, async () => { + const version = testing.number + testing.number = "4.0.0" + await expect(latest("https://example.com/releases/5.0.0")).to.eventually.equal("5.0.0") + await expect(latest("https://example.com/releases/3.0.0")).to.eventually.equal("4.0.0") + await expect(latest("https://example.com/releases/3.0")).to.eventually.equal("4.0.0") + testing.number = version +}) + +Deno.test(t(import.meta, "`latest()` fallbacks on current version on errors"), { permissions: "none" }, async () => { + const version = testing.number + testing.number = "4.0.0" + await expect(latest("https://example.com/releases/6.6.6")).to.eventually.equal(testing.number) + testing.number = version +}) diff --git a/source/plugins/.await/docs/about.md b/source/plugins/.await/docs/about.md new file mode 100644 index 00000000000..6aa8934ae2e --- /dev/null +++ b/source/plugins/.await/docs/about.md @@ -0,0 +1,31 @@ +--- +title: About `.await` plugin +--- + +The _metrics_ engine executes plugins in parallel to speed up response times, but sometimes it is required to wait for results collection before continuing. + +The `.await` meta-plugin acts as a synchronization barrier. + +It is especially useful when performing group operations such as rendering, transformations, publications, etc. which is why it's usually used in conjunction with processors. + +```yml +plugins: + # Plugins "a" and "b" are executed in parallel + - a: + - b: + # ".await" for previous plugins to be completed + - .await: + processors: + - render.content: + - publish.console: +``` + +Note that `.await` plugin is actually implicit when no plugin identifier is specified. + +```yml +plugins: + # ".await" is implicit + - processors: + - render.content: + - publish.console: +``` diff --git a/source/plugins/.await/mod.ts b/source/plugins/.await/mod.ts new file mode 100644 index 00000000000..009178a2969 --- /dev/null +++ b/source/plugins/.await/mod.ts @@ -0,0 +1,29 @@ +// Imports +import { is, Plugin } from "@engine/components/plugin.ts" + +/** Plugin */ +export default class extends Plugin { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "đŸšĨ Await other plugins execution" + + /** Category */ + readonly category = "control" + + /** Description */ + readonly description = "Await for all previous pending plugins to complete before continuing execution" + + /** Inputs */ + readonly inputs = is.object({}) + + /** Outputs */ + readonly outputs = is.object({}) + + /** Action */ + protected action() { + this.context.template = null + return Promise.resolve({}) + } +} diff --git a/source/plugins/.await/tests/list.yml b/source/plugins/.await/tests/list.yml new file mode 100644 index 00000000000..6113ca6cdec --- /dev/null +++ b/source/plugins/.await/tests/list.yml @@ -0,0 +1,3 @@ +- name: is supported + plugins: + - .await: diff --git a/source/plugins/.legacy/docs/about.md b/source/plugins/.legacy/docs/about.md new file mode 100644 index 00000000000..850739ef7dd --- /dev/null +++ b/source/plugins/.legacy/docs/about.md @@ -0,0 +1,22 @@ +--- +title: About `.legacy` plugin +--- + +The `.legacy` plugin can be used to execute plugins within a _metrics_ v3.x context or to run plugins that are not yet available in v4.x. + +It works by pulling the corresponding [docker](https://www.docker.com) image from [ghcr.io](https://github.com/lowlighter/metrics/pkgs/container/metrics) and running the container with the specified +inputs. + +```yml +plugins: + - .legacy: + version: v3.34 + inputs: + plugin_isocalendar: yes + plugin_isocalendar_duration: full-year +``` + +For supported inputs, please refer to the `action.yml` of the chosen docker image version. + +Note that some `🧱 Core` options are hardcoded or ignored for the purpose of this plugin (mainly options related to `config_output` and `output_action`, which are expected to be handled in the v4.x +context using processors). diff --git a/source/plugins/.legacy/docs/compatibility.md b/source/plugins/.legacy/docs/compatibility.md new file mode 100644 index 00000000000..240ea6dc747 --- /dev/null +++ b/source/plugins/.legacy/docs/compatibility.md @@ -0,0 +1,39 @@ +--- +title: About compatibility layer +--- + +For convenience, _metrics_ v4.x provides a compatibility layer to automatically transpile v3.x configurations to v4.x. Messages will be displayed in the console to indicate which changes were applied, +and to ease migration towards v4.x. at some point. + +```yml +# v3.x configuration +inputs: + base: "" + token: github_token + plugin_isocalendar: yes + plugin_isocalendar_duration: full-year + output_action: commit + committer_branch: metrics-renders +``` + +```yml +# v4.x configuration +presets: + default: + plugins: + token: github_token +plugins: + - calendar: + view: isometric + range: last-365-days + - processors: + - render.content: + format: svg + - publish.git: + commit: + filepath: github-metrics.* + message: Update ${file} - [Skip GitHub Action] + branch: metrics-renders +``` + +> ⚠ī¸ Note that the compatibility layer is trying its best to minimize changes, but the resulting configuration may still require some manual adjustments and may be more verbose than required. diff --git a/source/plugins/.legacy/mod.ts b/source/plugins/.legacy/mod.ts new file mode 100644 index 00000000000..2767f9da55b --- /dev/null +++ b/source/plugins/.legacy/mod.ts @@ -0,0 +1,134 @@ +// Imports +import { is, parse, Plugin } from "@engine/components/plugin.ts" +import { Logger } from "@engine/utils/log.ts" +import { command } from "@engine/utils/deno/command.ts" +import { throws } from "@engine/utils/errors.ts" +import { read } from "@engine/utils/deno/io.ts" +import { encodeBase64 } from "std/encoding/base64.ts" + +/** v3.x official templates */ +export const templates = ["classic", "repository", "terminal", "markdown"] + +/** Plugin */ +export default class extends Plugin { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "🏛ī¸ Legacy plugins execution" + + /** Category */ + readonly category = "legacy" + + /** Description */ + readonly description = "Executes plugins using metrics v3 docker image" + + /** Supports */ + readonly supports = ["user", "organization", "repository"] + + /** Inputs */ + readonly inputs = is.object({ + version: is.string().regex(/^v3\.[0-9]{1,2}$/).default("v3.34").describe("Metrics version (v3.x)"), + inputs: is.record(is.string(), is.unknown()).default(() => ({})).describe("Plugin inputs (as described from respective `action.yml`). Some core options are not supported and will have no effect"), + }) + + /** Outputs */ + readonly outputs = is.object({ + content: is.string().describe("Rendered content (base64 encoded)"), + }) + + /** Permissions */ + readonly permissions = ["run:docker", "write:tmp", "read:tmp"] + + /** Action */ + protected async action() { + const { handle } = this.context + const { version, inputs } = await parse(this.inputs, this.context.args) + + // Prepare context + const context = { + fixed: { + use_prebuilt_image: true, + config_base64: true, + output_action: "none", + filename: "metrics.legacy", + config_output: "svg", + }, + inherited: { + token: this.context.token.read(), + user: handle?.split("/")[0], + repo: handle?.split("/")[1], + template: { legacy: "classic" }[this.context.template!] ?? this.context.template, + config_timezone: this.context.timezone, + retries: this.context.retries.attempts, + retries_delay: this.context.retries.delay, + plugins_errors_fatal: this.context.fatal, + debug: Logger.channels[this.context.logs] >= Logger.channels.debug, + use_mocked_data: this.context.mock, + github_api_rest: this.context.api, + github_api_graphql: this.context.api, + }, + editable: [ + /^base(?:_|$)/, + /^repositories(?:_|$)/, + /^users_ignored$/, + /^commits_authoring$/, + /^markdown$/, + /^optimize$/, + /^setup_community_templates$/, + /^query$/, + /^extras_(?:css|js)$/, + /^config_(?:order|twemoji|gemoji|octicon|display|animations|padding|presets)$/, + /^delay$/, + /^quota_required_(?:rest|graphql|search)$/, + /^verify$/, + /^debug_flags$/, + /^experimental_features$/, + /^plugin_\w+$/, + ], + inputs: {} as Record, + } + Object.assign(context.inputs, context.fixed, context.inherited) + + // Register user inputs + for (const [key, value] of Object.entries(inputs)) { + if (key in context.fixed) { + this.log.warn(`ignoring ${key}: cannot be overriden in this context`) + continue + } + if ((key in context.inherited) || (context.editable.some((regex) => regex.test(key)))) { + this.log.trace(`registering: ${key}=${value}`) + context.inputs[key] = value + continue + } + this.log.warn(`ignoring ${key}: not supported in this context`) + } + if (context.inputs.template === "markdown") { + context.inputs.config_output = "markdown" + } + + // Execute docker image + const tmp = await Deno.makeTempDir({ prefix: "metrics_legacy_" }) + try { + const env = Object.fromEntries(Object.entries(context.inputs).map(([key, value]) => [`INPUT_${key.toLocaleUpperCase()}`, `${value ?? ""}`])) + this.log.trace(env) + const { success } = await command(`docker run --rm ${Object.keys(env).map((key) => `--env ${key}`).join(" ")} --volume=${tmp}:/renders ghcr.io/lowlighter/metrics:${version}`, { + log: this.log, + env, + }) + if (!success) { + throws("Failed to execute metrics") + } + let content = "" + try { + content = await read(`${tmp}/metrics.legacy`) + } // TODO(@lowlighter): fix this ?? + catch (error) { + console.error(error) + } + return { content: encodeBase64(content) } + } finally { + await Deno.remove(tmp, { recursive: true }) + } + } +} diff --git a/source/plugins/.legacy/templates/legacy.ejs b/source/plugins/.legacy/templates/legacy.ejs new file mode 100644 index 00000000000..505cc62c58d --- /dev/null +++ b/source/plugins/.legacy/templates/legacy.ejs @@ -0,0 +1,10 @@ +
+ <% if (result instanceof Error) { %> +
+ + <%= result.message %> +
+ <% } else { %> + + <% } %> +
diff --git a/source/plugins/.legacy/tests/list.yml b/source/plugins/.legacy/tests/list.yml new file mode 100644 index 00000000000..4356a31f06b --- /dev/null +++ b/source/plugins/.legacy/tests/list.yml @@ -0,0 +1,36 @@ +- name: supports `version` + plugins: + - .legacy: + version: v3.34 + token: MOCKED_TOKEN + handle: octocat + logs: none + processors: + - assert: + html: + select: .legacy img + count: 1= + - .legacy: + version: v3.99 + handle: octocat + fatal: false + logs: none + processors: + - assert: + error: /failed to execute metrics/i + +- name: supports `inputs` + plugins: + - .legacy: + inputs: + use_prebuilt_image: false + dryrun: true + base: header + token: MOCKED_TOKEN + handle: octocat + logs: none + processors: + - assert: + html: + select: .legacy img + count: 1= diff --git a/source/plugins/activity/mod.ts b/source/plugins/activity/mod.ts new file mode 100644 index 00000000000..f72377f81ca --- /dev/null +++ b/source/plugins/activity/mod.ts @@ -0,0 +1,345 @@ +// deno-lint-ignore-file no-explicit-any no-unused-vars no-unreachable +//TODO(@lowlighter): finish implementation, use the correct language color from cache +// Imports +import { is, parse, Plugin } from "@engine/components/plugin.ts" +import { ignored, matchPatterns, reactions } from "@engine/utils/github.ts" +import { markdown } from "@engine/utils/markdown.ts" + +const user = is.object({ + login: is.string().describe("User login"), + avatar: is.string().describe("User avatar url"), + type: is.string().describe("User type"), +}) + +const repository = is.object({ + name: is.string().describe("Repository name"), + owner: user.describe("Repository owner"), + description: is.string().describe("Repository description"), + fork: is.boolean().describe("Repository is a fork"), + template: is.boolean().describe("Repository is a template"), + archived: is.boolean().describe("Repository is archived"), + created: is.date().describe("Repository creation date"), + stargazers: is.number().int().min(0).describe("Repository number of stargazers"), + watchers: is.number().int().min(0).describe("Repository number of watchers"), + forks: is.number().int().min(0).describe("Repository number of forks"), + issues: is.number().int().min(0).describe("Repository number of open issues"), + language: is.object({ + name: is.string().describe("Repository language name"), + color: is.string().describe("Repository language color"), + }).describe("Repository language"), + license: is.string().nullable().describe("Repository license"), + topics: is.array(is.string()).describe("Repository topics"), +}) + +const issue = is.object({ + author: user.describe("Issue author"), + number: is.number().int().min(0).describe("Issue number"), + title: is.string().describe("Issue title"), + content: is.string().nullable().describe("Issue content"), + labels: is.array(is.object({ name: is.string(), color: is.string() })).describe("Issue labels"), + assignees: is.array(user).describe("Issue assignees"), + milestone: is.string().nullable().describe("Issue milestone"), + comments: is.number().int().min(0).describe("Issue number of comments"), + reactions: is.record(is.number().int().min(0)).describe("Issue reactions"), +}) + +const event = { + common: is.object({ + timestamp: is.number().int().min(0).describe("Event timestamp"), + actor: user.describe("Event actor"), + repository: repository.pick({ name: true }).describe("Event repository"), + }), + user, + repository, + issue, +} + +/** Plugin */ +export default class extends Plugin { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "📰 Recent activity" + + /** Category */ + readonly category = "github" + + /** Description */ + readonly description = "Display recent activity on GitHub" + + /** Supports */ + readonly supports = ["user", "organization", "repository"] + + /** Inputs */ + readonly inputs = is.object({ + visibility: is.union([ + is.literal("public").describe("Includes public events only"), + is.literal("all").describe("Includes public and private events (n.b. still subject to token permissions)"), + ]).default("public").describe("Activity events visibility"), + users: is.object({ + matching: is.preprocess((value) => [value].flat(), is.array(is.string())).default(() => ["*", ...ignored.users]).describe("Include users matching at least one of these patterns"), + }).default(() => ({})).describe("Users options"), + repositories: is.object({ + matching: is.preprocess((value) => [value].flat(), is.array(is.string())).default(() => ["*/*", ...ignored.repositories]).describe( + "Include repositories matching at least one of these patterns", + ), + }).default(() => ({})).describe("Repositories options"), + + limit: is.number().int().min(1).nullable().default(5).describe("Display limit. Set to `null` to disable"), + }) + + /** Outputs */ + readonly outputs = is.object({ + events: is.array(is.union([ + event.common.extend({ + type: is.literal("issue").describe("Event type"), + action: is.string().describe("Event action"), + issue: event.issue.describe("Event issue"), + }), + /* + event.common.merge(event.user).extend({ + type: is.literal("pullrequest").describe("Event type"), + number: is.number().int().min(0).describe("Pull request number"), + title: is.string().describe("Pull request title"), + content: is.string().describe("Pull request content"), + labels: is.array(is.object({name: is.string(), color: is.string()})).describe("Pull request labels"), + assignees: is.array(event.user).describe("Pull request assignees"), + milestone: is.string().nullable().describe("Pull request milestone"), + comments: is.number().int().min(0).describe("Pull request number of content comments"), + reactions: is.record(is.number().int().min(0)).describe("Pull request reactions"), + draft: is.boolean().describe("Pull request is a draft"), + head: is.object({ + branch: is.string().describe("Pull request head branch"), + repository: is.string().describe("Pull request head repository"), + }).describe("Pull request head"), + base: is.object({ + branch: is.string().describe("Pull request base branch"), + repository: is.string().describe("Pull request base repository"), + }).describe("Pull request base"), + changes: is.object({ + additions: is.number().int().min(0).describe("Pull request number of additions"), + deletions: is.number().int().min(0).describe("Pull request number of deletions"), + files: is.number().int().min(0).describe("Pull request number of changed files"), + }).describe("Pull request changes"), + }), + event.common.merge(event.user).extend({ + type: is.literal("release").describe("Event type"), + tag: is.string().describe("Release tag"), + title: is.string().describe("Release title"), + content: is.string().describe("Release patchnote"), + draft: is.boolean().describe("Release is a draft"), + prerelease: is.boolean().describe("Release is a prerelease"), + mentions: is.array(event.user) + }),*/ + event.common.extend({ + type: is.literal("star").describe("Event type"), + repository: event.repository.describe("Event repository"), + }), + ])).describe("Activity events"), + }) + + /** EJS template additional rendering context */ + protected _renderctx = { markdown } + + /** Action */ + protected async action() { + const { handle } = this.context + const { users } = await parse(this.inputs, this.context.args) + + let events = await this.rest(this.api.activity.listPublicEventsForUser, { username: handle! }, { paginate: true }) + + events = [ + ...await Promise.all( + events.map( + async ( + { type, created_at, payload, actor, repo }: { type: string; created_at: string; payload: Record; actor: { login: string; avatar_url: string }; repo: { name: string } }, + ) => { + const event = { timestamp: new Date(created_at).getTime(), actor: { login: actor.login, avatar: actor.avatar_url, type: "user" }, repository: { name: repo.name } } + switch (type) { + case "CommitCommentEvent": { + return null + } + case "CreateEvent": { + return null + } + case "DeleteEvent": { + return null + } + case "ForkEvent": { + return null + } + case "GollumEvent": { + return null + } + case "IssueCommentEvent": { + return null + } + // Issue created + case "IssuesEvent": { + if (!["opened", "edited", "closed", "reopened", "assigned", "unassigned"].includes(payload.action)) { + return null + } + if (!matchPatterns(users.matching, payload.issue.user.login)) { + return null + } + return Object.assign(event, { type: "issue", action: payload.action, issue: this.issue(payload.issue) }) + } + case "MemberEvent": { + return null + } + case "PublicEvent": { + return null + } + // Pull request created + case "PullRequestEvent": { + return null + if (!["opened", "edited", "closed", "reopened", "assigned", "unassigned"].includes(payload.action)) { + return null + } + if (!matchPatterns(users.matching, payload.pull_request.user.login)) { + return null + } + return Object.assign(event, { type: "pullrequest", action: payload.action, ...this.pullrequest(payload.pull_request) }) + } + case "PullRequestReviewEvent": { + return null + } + case "PullRequestReviewCommentEvent": { + //console.log(payload) + return null + } + case "PullRequestReviewThreadEvent": { + return null + } + case "PushEvent": { + return null //Object.assign(event, {type:"push", ...this.push(payload)}) + } + // Release published + case "ReleaseEvent": { + return null + if (payload.action !== "published") { + return null + } + return Object.assign(event, { type: "release", ...this.release(payload.release) }) + } + // + case "SponsorshipEvent": { //created + return null + } + //Repository starred + case "WatchEvent": { + if (payload.action !== "started") { + return null + } + const { data } = await this.rest(this.api.repos.get, { owner: repo.name.split("/")[0], repo: repo.name.split("/")[1] }) + return Object.assign(event, { type: "star", repository: this.repository(data) }) + } + default: { + //console.log(type, payload) + } + } + }, + ), + ), + ].filter((event) => event) + + console.log(events) + return { events } + } + + private repository(repository: any) { + return { + name: repository.full_name, + owner: { + login: repository.owner.login, + avatar: repository.owner.avatar_url, + type: repository.owner.type.toLocaleLowerCase(), + }, + description: repository.description, + fork: repository.fork, + template: repository.is_template, + archived: repository.archived, + created: new Date(repository.created_at), + stargazers: repository.stargazers_count, + watchers: repository.watchers_count, + forks: repository.forks_count, + language: { + name: repository.language, + color: "#959da5", + }, + issues: repository.open_issues_count, + license: repository.license?.name ?? null, + topics: repository.topics, + } + } + + private comment(comment: any) { + return {} + } + + private push(push: any) { + return { + branch: push.ref.replace(/^refs\/heads\//, ""), + commits: push.commits.map(({ sha, message }: Record) => ({ sha, message })).reverse(), + } + } + + /** Format release content */ + private release(release: any) { + return { + user: { + login: release.author.login, + avatar: release.author.avatar_url, + }, + tag: release.tag_name, + title: release.name, + content: release.body, + draft: release.draft, + prerelease: release.prerelease, + mentions: release.mentions?.map(({ login, avatar_url }: Record) => ({ login, avatar: avatar_url })) ?? [], + } + } + + /** Format issue content */ + private issue(issue: any) { + return { + author: { + login: issue.user.login, + avatar: issue.user.avatar_url, + type: issue.user.type.toLocaleLowerCase(), + }, + number: issue.number, + title: issue.title, + content: issue.body, + labels: issue.labels.map(({ name, color }: Record) => ({ name, color })), + assignees: issue.assignees.map(({ login, avatar_url, type }: Record) => ({ login, avatar: avatar_url, type: issue.user.type.toLocaleLowerCase() })), + milestone: issue.milestone?.title ?? null, + comments: issue.comments, + reactions: Object.fromEntries(Object.entries(issue.reactions ?? {}).filter(([key]) => key in reactions.rest).map(([key, value]) => [reactions.rest[key as keyof typeof reactions.rest], value])), + } + } + + private pullrequest(pullrequest: any) { + //merged + return { + ...this.issue(pullrequest), + draft: pullrequest.draft, + //review_comments + head: { + branch: pullrequest.head.label, + repository: pullrequest.head.repo.full_name, + }, + base: { + branch: pullrequest.base.label, + repository: pullrequest.base.repo.full_name, + }, + changes: { + additions: pullrequest.additions, + deletions: pullrequest.deletions, + files: pullrequest.changed_files, + }, + //merged_by user ? + } + } +} diff --git a/source/plugins/activity/templates/classic.ejs b/source/plugins/activity/templates/classic.ejs new file mode 100644 index 00000000000..b3aa164a6f6 --- /dev/null +++ b/source/plugins/activity/templates/classic.ejs @@ -0,0 +1,330 @@ +
+
+ + Recent activity +
+
+ <% if (result instanceof Error) { %> +
+ + <%= result.message %> +
+ <% } else { %> +
+ <% for (const {type, ...event} of result.events) { %> +
+
+ <% if (type === "star") { %> + + <% } %> + <% if (type === "issue") { %> + <% if (event.action === "opened") { %> + + <% } else if (event.action === "reopened") { %> + + <% } else if (event.action === "closed") { %> + + <% } else { %> + + <% } %> + <% } %> +
+
+ +
+ + <% if (type === "star") { %> +
<%= event.actor.login %> starred a repository
+ <% } %> + <% if (type === "issue") { %> +
<%= event.actor.login %> <%= event.action %> issue #<%= event.issue.number %> in <%= event.repository.name %>
+ <% } %> +
+ + + <% if (event.issue) { %> +
+
+ +
+
+ <% if (event.issue.author.login !== event.actor.login) { %> +
+ +
opened by <%= event.issue.author.login %>
+
+ <% } %> + <% if (event.issue.labels.length > 0) { %> +
+ <% for (const label of event.issue.labels) { %> + <%= label.name %> + <% } %> +
+ <% } %> +
+ <% if (event.issue.content) { %> +
<%- await markdown(event.issue.content) %>
+ <% } %> + +
+ <% } %> + + + <% if (event.repository.description) { %> +
+
+
+ + <% if (event.repository.fork) { %> + + <% } else { %> + + <% } %> +
+ +
+
<%= event.repository.description %>
+ <% if (event.repository.topics.length > 0) { %> +
+ <% for (const topic of event.repository.topics) { %> + <%= topic %> + <% } %> +
+ <% } %> + +
+ <% } %> +
+
+ <% } %> +
+ <% } %> +
+ +
+ + + + + + + \ No newline at end of file diff --git a/source/plugins/activity/tests/list.yml b/source/plugins/activity/tests/list.yml new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/source/plugins/activity/tests/list.yml @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/source/plugins/activity/tests/rest.ts b/source/plugins/activity/tests/rest.ts new file mode 100644 index 00000000000..814abf92e2d --- /dev/null +++ b/source/plugins/activity/tests/rest.ts @@ -0,0 +1,8 @@ +import { mock, Status } from "@engine/utils/testing.ts" + +export default { + "/zen": mock({}, () => ({ + status: Status.OK, + data: new TextEncoder().encode("Anything added dilutes everything else."), + })), +} diff --git a/source/plugins/calendar/mod.ts b/source/plugins/calendar/mod.ts new file mode 100644 index 00000000000..59629b8f4ea --- /dev/null +++ b/source/plugins/calendar/mod.ts @@ -0,0 +1,278 @@ +// Imports +import { is, parse, Plugin } from "@engine/components/plugin.ts" + +/** Plugin */ +export default class extends Plugin { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "📆 Commit calendar" + + /** Category */ + readonly category = "github" + + /** Supports */ + readonly supports = ["user"] + + /** Description */ + readonly description = "Displays a commit calendar along with a few additional statistics" + + /** Inputs */ + readonly inputs = is.object({ + view: is.union([ + is.literal("isometric").describe("Isometric view"), + is.literal("top-down").describe("Top-down view"), + ]).default("isometric").describe("Calendar view"), + range: is.union([ + is.union([ + is.literal("last-180-days").describe("Display last 180 days"), + is.literal("last-365-days").describe("Display last 365 days"), + is.literal("current-year").describe("Display current year (starting from january 1st)"), + ]).describe("Predefined range"), + is.number().min(1970).describe(`Set to specific year (e.g. \`${new Date().getFullYear()}\`)`), + is.object({ + from: is.union([ + is.literal("registration").describe("Set to user's registration year"), + is.number().min(1970).describe(`Set to specific year (e.g. \`${new Date().getFullYear()}\`)`), + is.number().negative().describe("Set year relative to `range.to` value"), + ]).default("registration").describe("Starting year"), + to: is.union([ + is.literal("current-year").describe("Set to current year"), + is.number().min(1970).describe(`Set to specific year (e.g. \`${new Date().getFullYear()}\`)`), + ]).default("current-year").describe("Ending year"), + }).describe("Custom range"), + ]).default("last-365-days").describe("Year range"), + colors: is.union([ + is.union([ + is.literal("auto").describe("Use current GitHub theme"), + is.literal("halloween").describe("Force Halloween theme"), + is.literal("winter").describe("Force Winter theme"), + ]).describe("Predefined color scheme"), + is.array(is.string()).length(5).default(() => ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"]).describe("Use a custom color scheme. Each color represents a commit quartile level"), + ]).default("auto").describe("Color scheme"), + isometric: is.object({ + max_cap: is.number().int().min(1).nullable().default(null).describe( + "Cap the value used to compute the height of each day to mitigate graphs distorted by distant values. Set to `null` to disable", + ), + }).nullable().default(null).describe("Isometric view options (n.b. only applies to `isometric` view)"), + }) + + /** Outputs */ + readonly outputs = is.object({ + range: is.object({ + start: is.coerce.date().describe("Starting year"), + end: is.coerce.date().describe("Ending date"), + average: is.number().min(0).describe("Average number of commits per day"), + max: is.number().int().min(0).describe("Highest number of commits in a single day"), + streak: is.object({ + max: is.number().int().min(0).describe("Highest number of consecutive days with commits"), + current: is.number().int().min(0).describe("Current number of consecutive days with commits"), + }).describe("Streak statistics"), + }).describe("Year range"), + calendar: is.object({ + colors: is.enum(["default", "halloween", "winter", "custom"]).describe("Color scheme"), + years: is.array(is.object({ + year: is.union([is.string(), is.number().int().positive()]).describe("Year"), + weeks: is.array(is.object({ + days: is.array( + is.object({ + date: is.coerce.date().describe("Date"), + count: is.number().int().min(0).describe("Number of commits this day"), + level: is.number().min(0).max(4).describe("Commit quartile level this day"), + }).nullable(), + ).describe("Days statistics. Set to `null` if day is outside of current year"), + })).describe("Weeks statistics"), + average: is.number().min(0).describe("Average number of commits per day for this year"), + max: is.number().int().min(0).describe("Highest number of commits in a single day for this year"), + streak: is.object({ + max: is.number().int().min(0).describe("Highest number of consecutive days with commits for this year"), + }).describe("Streak statistics"), + })), + }).describe("Calendar statistics"), + }) + + /** Action */ + protected async action() { + const { handle } = this.context + const { range, ...inputs } = await parse(this.inputs, this.context.args) + + //Color scheme + const { entity: { contributions: { calendar: { colors: [color] } } } } = await this.graphql("colors", { login: handle }) + const colors = Array.isArray(inputs.colors) ? "custom" : inputs.colors !== "auto" ? inputs.colors : { "#ffee4a": "halloween", "#0a3069": "winter" }[color as string] ?? "default" + + //Compute date range + const lastXdays = /^last-(?\d+)-days$/ + const today = new Date() + let end = this.date(today, { time: "23:59" }) + let start = this.date(today, { time: "00:00" }) + if (typeof range === "object") { + // End date + switch (true) { + case range.to === "current-year": + end = this.date(today.getFullYear(), { day: "31st dec", time: "23:59" }) + break + case typeof range.to === "number": + end = this.date(range.to as number, { day: "31st dec", time: "23:59" }) + break + } + // Start date + switch (true) { + case range.from === "registration": { + start = this.date(new Date((await this.graphql("user", { login: handle })).entity.registration), { time: "00:00" }) + break + } + case typeof range.from === "number": + if (range.from as number < 0) { + start = this.date(end.getFullYear() + (range.from as number), { day: "1st jan", time: "00:00" }) + } else { + start = this.date(range.from as number, { day: "1st jan", time: "00:00" }) + } + break + } + } // Specific year + else if (typeof range === "number") { + start = this.date(range, { day: "1st jan", time: "00:00" }) + end = (range === today.getFullYear()) ? this.date(today, { time: "keep" }) : this.date(range, { day: "31st dec", time: "23:59" }) + } // Current year + else if (range === "current-year") { + start = this.date(today.getFullYear(), { day: "1st jan", time: "00:00" }) + end = this.date(today, { time: "keep" }) + } // Last X days + else if (lastXdays.test(`${range}`)) { + const n = Number((range as string).match(lastXdays)!.groups!.n) + end = this.date(today, { time: "keep" }) + start = this.date(end, { time: "keep" }) + start.setDate(start.getDate() - n) + } + this.log.trace(`start date set from year "${typeof range === "object" ? range.from : range}" → ${this.format(start)}`) + this.log.trace(`end date set from "${typeof range === "object" ? range.to : range}" → ${this.format(end)}`) + + //Fetch data + const calendar = { colors, years: [] } as is.infer + const result = { range: { start, end, average: 0, max: 0, streak: { max: 0, current: 0 } }, calendar } + this.log.debug(`fetching data from ${this.format(start)} to ${this.format(end)}`) + for (let year = start.getFullYear(); year <= end.getFullYear(); year++) { + this.log.trace(`processing ${lastXdays.test(`${range}`) ? `"${range}"` : `year ${year}`}`) + calendar.years.push({ year, weeks: [], average: 0, max: 0, streak: { max: 0 } }) + + //Ensure starting date is on a sunday (to avoid incomplete week arrays that would offset results later on) + const A = lastXdays.test(`${range}`) ? this.date(start, { time: "keep" }) : this.date(year, { day: "1st jan", time: "00:00" }) + const O = this.date(A, { time: "keep" }) + A.setDate(A.getDate() - A.getDay()) + + //Ensure ending date is either on today or a 31st of december + const B = lastXdays.test(`${range}`) ? this.date(end, { time: "keep" }) : (year >= today.getFullYear()) ? this.date(today, { time: "keep" }) : this.date(year, { day: "31st dec", time: "23:59" }) + + //Iterate over year + for (let a = this.date(A, { time: "keep" }), first = true; a < B; first = false) { + //Compute next date range + let b = this.date(a, { time: "23:59" }) + b.setDate(b.getDate() + 27) + if (b > B) { + b = this.date(B, { time: "keep" }) + } + + //Fetch data from api and clean padded days on first iteration + this.log.trace(`querying data from ${this.format(a)} to ${this.format(b)}`) + const from = this.date(a, { time: "keep", utc: true }).toISOString() + const to = this.date(b, { time: "keep", utc: true }).toISOString() + const { entity: { contributions: { calendar: { weeks } } } } = await this.graphql("calendar", { login: handle, from, to }) + if (first) { + for (let i = 0; i < weeks[0].days.length; i++) { + const { date } = weeks[0].days[i] + if (this.date(date, { time: "00:00" }) < O) { + weeks[0].days[i] = null + } + } + } + calendar.years.at(-1)!.weeks.push(...weeks) + this.log.trace(`fetched ${(weeks as Array<{ days: unknown[] }>).flatMap(({ days }) => days).filter((day) => day).length} days`) + + //Set next date range + a = this.date(b, { time: "00:00" }) + a.setDate(a.getDate() + 1) + } + + //Set custom title for special ranges + if (lastXdays.test(`${range}`)) { + calendar.years.at(-1)!.year = { "last-180-days": "Last 180 days", "last-365-days": "Last 365 days" }[range as string] ?? "" + break + } + } + this.log.debug(`fetched ${calendar.years.flatMap(({ weeks }) => weeks.flatMap(({ days }) => days)).filter((day) => day).length} days in total`) + + //Compute streaks and average + const global = [] as number[] + for (const year of calendar.years) { + this.log.trace(`computing additional data for ${lastXdays.test(`${range}`) ? `"${range}"` : `year ${year.year}`}`) + const local = [] as number[] + let streak = 0 + for (const { days } of year.weeks) { + for (const day of days) { + if (!day) { + continue + } + local.push(day.count) + streak = day.count ? streak + 1 : 0 + result.range.streak.current = day.count ? result.range.streak.current + 1 : 0 + year.max = Math.max(year.max, day.count) + year.streak.max = Math.max(year.streak.max, streak) + day.level = { NONE: 0, FIRST_QUARTILE: 1, SECOND_QUARTILE: 2, THIRD_QUARTILE: 3, FOURTH_QUARTILE: 4 }[day.level as unknown as string] ?? 0 + } + } + year.average = local.reduce((a, b) => a + b, 0) / (local.length || 1) + global.push(...local) + } + result.range.streak.max = Math.max(...calendar.years.map((year) => year.streak.max)) + result.range.max = Math.max(0, ...global) + result.range.average = global.reduce((a, b) => a + b, 0) / (global.length || 1) + + return result + } + + /** Format date for debug */ + private format(date: Date) { + const day = new Intl.DateTimeFormat("en-GB", { timeZone: this.context.timezone, weekday: "short" }).format(date) + const fdate = new Intl.DateTimeFormat("en-GB", { timeZone: this.context.timezone, year: "numeric", month: "2-digit", day: "2-digit" }).format(date) + const time = new Intl.DateTimeFormat("en-GB", { timeZone: this.context.timezone, hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3 }).format(date) + return `${day} ${fdate} ${time}` + } + + /** Create a date from a reference, and optionally clean time */ + private date(date: Date, options: { time: "00:00" | "23:59" | "keep"; utc?: boolean }): Date + private date(year: number, options: { day: "1st jan" | "31st dec"; time: "00:00" | "23:59"; utc?: boolean }): Date + private date(ref: number | Date, { day, time, utc }: { day?: "1st jan" | "31st dec"; time: "00:00" | "23:59" | "keep"; utc?: boolean }) { + const date = new Date(new Date().toLocaleString("en", { timeZone: this.context.timezone })) + switch (typeof ref) { + case "number": + date.setFullYear(ref) + if (day) { + date.setMonth({ "1st jan": 0, "31st dec": 11 }[day]) + date.setDate({ "1st jan": 1, "31st dec": 31 }[day]) + } + break + case "object": + date.setTime(ref.getTime()) + break + } + switch (time) { + case "00:00": + date.setHours(0) + date.setMinutes(0) + date.setSeconds(0) + date.setMilliseconds(0) + break + case "23:59": + date.setHours(23) + date.setMinutes(59) + date.setSeconds(59) + date.setMilliseconds(999) + break + } + if (utc) { + date.setMinutes(date.getMinutes() - date.getTimezoneOffset()) + } + return date + } +} diff --git a/source/plugins/calendar/queries/calendar.graphql b/source/plugins/calendar/queries/calendar.graphql new file mode 100644 index 00000000000..26fcba9f040 --- /dev/null +++ b/source/plugins/calendar/queries/calendar.graphql @@ -0,0 +1,15 @@ +query Calendar($login: String!, $from: DateTime!, $to: DateTime!) { + entity: user(login: $login) { + contributions: contributionsCollection(from: $from, to: $to) { + calendar: contributionCalendar { + weeks { + days: contributionDays { + count: contributionCount + level: contributionLevel + date + } + } + } + } + } +} \ No newline at end of file diff --git a/source/plugins/calendar/queries/colors.graphql b/source/plugins/calendar/queries/colors.graphql new file mode 100644 index 00000000000..b62ef2f4640 --- /dev/null +++ b/source/plugins/calendar/queries/colors.graphql @@ -0,0 +1,9 @@ +query CalendarColors($login: String!) { + entity: user(login: $login) { + contributions: contributionsCollection { + calendar: contributionCalendar { + colors + } + } + } +} \ No newline at end of file diff --git a/source/plugins/calendar/queries/user.graphql b/source/plugins/calendar/queries/user.graphql new file mode 100644 index 00000000000..47360d645a1 --- /dev/null +++ b/source/plugins/calendar/queries/user.graphql @@ -0,0 +1,5 @@ +query CalendarUser($login: String!) { + entity: user(login: $login) { + registration: createdAt + } +} \ No newline at end of file diff --git a/source/plugins/calendar/templates/classic.ejs b/source/plugins/calendar/templates/classic.ejs new file mode 100644 index 00000000000..404ccca5569 --- /dev/null +++ b/source/plugins/calendar/templates/classic.ejs @@ -0,0 +1,112 @@ +
+
+ + Contributions calendar + <% if (!(result instanceof Error)) { %> + (<%= format.date(result.range.start) %> to <%= format.date(result.range.end) %>) + <% } %> +
+
+ <% if (result instanceof Error) { %> +
+ + <%= result.message %> +
+ <% } else { const scope = crypto.randomUUID(), cap = Math.min(Math.max(...result.calendar.years.map(({max}) => max)), args.isometric?.max_cap || Infinity) %> +
+ <% for (const {year, weeks, average, max, streak} of result.calendar.years.reverse()) { %> +
year year-<%= `${year}`.replace(/[^\w]/g, "_") %>"> + + +
+ <% } %> +
+ <% if (Array.isArray(args.colors)) { %> + <%- `` %> + <% } %> + <% if (args.view === "isometric") { %> + + <% } %> + <% } %> +
+
+ + + diff --git a/source/plugins/calendar/tests/calendar.graphql.ts b/source/plugins/calendar/tests/calendar.graphql.ts new file mode 100644 index 00000000000..f3d7ae276bd --- /dev/null +++ b/source/plugins/calendar/tests/calendar.graphql.ts @@ -0,0 +1,32 @@ +import { faker, is, mock } from "@engine/utils/testing.ts" + +export default mock({ login: is.string(), from: is.coerce.date(), to: is.coerce.date() }, ({ from, to }) => { + let days = [] + const weeks = [] + const distribution = [[0, 50], [1, 15], [2, 15], [3, 5], [4, 5], [5, 3], [6, 1], [7, 1], [8, 1], [9, 1], [10, 1], [11, 1], [12, 1]].flatMap(([n, r]) => new Array(r).fill(n)) + const max = Math.max(...distribution) + if (from.getFullYear() > 1970) { + for (const date = new Date(from); date <= to; date.setUTCDate(date.getUTCDate() + 1)) { + if ((!date.getUTCDay()) && (date.getTime() !== from.getTime())) { + weeks.push({ days }) + days = [] + } + const count = faker.helpers.arrayElement(distribution) + days.push({ + count, + level: count ? ["FIRST_QUARTILE", "SECOND_QUARTILE", "THIRD_QUARTILE", "FOURTH_QUARTILE"][Math.floor(3 * count / max)] as string : "NONE", + date: new Date(date), + }) + } + } + weeks.push({ days }) + return { + entity: { + contributions: { + calendar: { + weeks, + }, + }, + }, + } +}) diff --git a/source/plugins/calendar/tests/colors.graphql.ts b/source/plugins/calendar/tests/colors.graphql.ts new file mode 100644 index 00000000000..79ae7962f5b --- /dev/null +++ b/source/plugins/calendar/tests/colors.graphql.ts @@ -0,0 +1,16 @@ +import { is, mock } from "@engine/utils/testing.ts" + +export default mock({ login: is.string() }, () => ({ + entity: { + contributions: { + calendar: { + colors: [ + "#9be9a8", + "#40c463", + "#30a14e", + "#216e39", + ], + }, + }, + }, +})) diff --git a/source/plugins/calendar/tests/list.yml b/source/plugins/calendar/tests/list.yml new file mode 100644 index 00000000000..7970904973c --- /dev/null +++ b/source/plugins/calendar/tests/list.yml @@ -0,0 +1,179 @@ +- name: supports `range:"last-180-days"` + plugins: + - calendar: + view: isometric + range: last-180-days + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 1= + - assert: + html: + select: .calendar .year .day + count: 180~1 + - calendar: + view: top-down + range: last-180-days + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 1= + - assert: + html: + select: .calendar .year .day + count: 180~1 + +- name: supports `range:"last-365-days"` + plugins: + - calendar: + view: isometric + range: last-365-days + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 1= + - assert: + html: + select: .calendar .year .day + count: 365~1 + - calendar: + view: top-down + range: last-365-days + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 1= + - assert: + html: + select: .calendar .year .day + count: 365~1 + +- name: supports `range:"current-year"` + plugins: + - calendar: + range: current-year + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 1= + - assert: + html: + select: .calendar .title sub + match: /1 jan (\d+) to \d+ \w+ \1/i + +- name: supports `range` with specific year + plugins: + - calendar: + range: 2020 + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 1= + - assert: + html: + select: .calendar .title sub + match: /1 jan 2020 to 31 dec 2020/i + - calendar: + range: 1970 + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 1= + - assert: + html: + select: .calendar .title sub + match: /1 jan 1970 to 31 dec 1970/i + +- name: supports `range.from` and `range.to` with specific years + plugins: + - calendar: + range: + from: 2019 + to: 2020 + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 2= + - assert: + html: + select: .calendar .title sub + match: /1 jan 2019 to 31 dec 2020/i + +- name: supports `range` relative years + plugins: + - calendar: + range: + from: -1 + to: current-year + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 2= + - calendar: + range: + from: -1 + to: 2020 + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 2= + - assert: + html: + select: .calendar .title sub + match: /1 jan 2019 to 31 dec 2020/i + +- name: supports `range:"registration"` + plugins: + - calendar: + range: + from: registration + to: current-year + handle: octocat + processors: + - assert: + html: + select: .calendar .year + count: 1>= + +- name: supports `colors` schemes + plugins: + - calendar: + view: top-down + colors: halloween + handle: octocat + processors: + - assert: + html: + select: .calendar svg.render [fill^="var"] + match: /fill="var\(--calendar-halloween-L\d\)"/ + raw: true + - calendar: + view: isometric + colors: winter + handle: octocat + processors: + - assert: + html: + select: .calendar svg.render [fill^="var"] + match: /fill="var\(--calendar-winter-L\d\)"/ + raw: true diff --git a/source/plugins/calendar/tests/user.graphql.ts b/source/plugins/calendar/tests/user.graphql.ts new file mode 100644 index 00000000000..fce577aecea --- /dev/null +++ b/source/plugins/calendar/tests/user.graphql.ts @@ -0,0 +1,7 @@ +import { faker, is, mock } from "@engine/utils/testing.ts" + +export default mock({ login: is.string() }, () => ({ + entity: { + registration: faker.date.past({ years: 3 }), + }, +})) diff --git a/source/plugins/gists/docs/issues.md b/source/plugins/gists/docs/issues.md new file mode 100644 index 00000000000..37f5c4c8e3d --- /dev/null +++ b/source/plugins/gists/docs/issues.md @@ -0,0 +1,16 @@ +--- +title: Known issue when using a fine-grained personal access token +type: warning +--- + +Even if the provided fine-grained personal access token has the `gists` scope, the GitHub GraphQL API seems to refuse serving gist data. + +As a workaround, it is possible to create a +[classic personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) and to +override the token used for this plugin. + +```yml +plugins: + - gists: + token: github_token_classic +``` diff --git a/source/plugins/gists/mod.ts b/source/plugins/gists/mod.ts new file mode 100644 index 00000000000..0e38e6395e5 --- /dev/null +++ b/source/plugins/gists/mod.ts @@ -0,0 +1,66 @@ +// Imports +import { is, parse, Plugin, state } from "@engine/components/plugin.ts" + +/** Plugin */ +export default class extends Plugin { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "đŸŽĢ Gists" + + /** Category */ + readonly category = "github" + + /** Supports */ + readonly supports = ["user"] + + /** Description */ + readonly description = "Displays [GitHub gists](https://gist.github.com)" + + /** Inputs */ + readonly inputs = is.object({ + forks: is.boolean().default(false).describe("Include forked gists"), + visibility: is.union([ + is.literal("public").describe("Includes public gists only"), + is.literal("all").describe("Includes public and private gists (n.b. still subject to token permissions)"), + ]).default("public").describe("Gists visibility"), + }) + + /** Outputs */ + readonly outputs = is.object({ + count: is.number().int().min(0).describe("Total number of gists"), + forked: is.number().int().min(0).describe("Total number of forked gists"), + comments: is.number().int().min(0).describe("Total number of comments"), + files: is.number().int().min(0).describe("Total number of files"), + forks: is.number().int().min(0).describe("Total number of forks"), + stargazers: is.number().int().min(0).describe("Total number of stargazers"), + }) + + /** Action */ + protected async action({ errors }: state) { + const { handle } = this.context + const { forks, visibility } = await parse(this.inputs, this.context.args) + const { entity: { gists: { count, nodes: gists } } } = await this.graphql("gists", { login: handle, privacy: visibility.toLocaleUpperCase() }, { paginate: true }) + const result = { count, forked: 0, comments: 0, files: 0, forks: 0, stargazers: 0 } + let missing = 0 + for (const gist of gists) { + if (!gist) { + missing++ + continue + } + if ((gist.forked) && (!forks)) { + continue + } + result.forked += gist.forked ? 1 : 0 + result.stargazers += gist.stargazers + result.forks += gist.forks.count + result.comments += gist.comments.count + result.files += gist.files.length + } + if (missing) { + errors.push({ severity: "warning", source: this.id, message: `${missing} gists could not be correctly fetched.\nProvided token may not have sufficient permissions.` }) + } + return result + } +} diff --git a/source/plugins/gists/queries/gists.graphql b/source/plugins/gists/queries/gists.graphql new file mode 100644 index 00000000000..27e7e8eced4 --- /dev/null +++ b/source/plugins/gists/queries/gists.graphql @@ -0,0 +1,24 @@ +query Gists($login: String!, $privacy: GistPrivacy!, $cursor: String) { + entity: user(login: $login) { + gists(after: $cursor, first: 50, orderBy: {field: UPDATED_AT, direction: DESC}, privacy: $privacy) { + count: totalCount + nodes { + stargazers: stargazerCount + forked: isFork + forks { + count: totalCount + } + files { + name + } + comments { + count: totalCount + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} \ No newline at end of file diff --git a/source/plugins/gists/templates/classic.ejs b/source/plugins/gists/templates/classic.ejs new file mode 100644 index 00000000000..ad48a57ea01 --- /dev/null +++ b/source/plugins/gists/templates/classic.ejs @@ -0,0 +1,44 @@ +
+
+ + <% if (!(result instanceof Error)) { %> + <%= format.number("Gist", result.count) %> + <% if (result.forked) { %> + (including <%= format.number("fork", result.forked) %>) + <% } %> + <% } else { %> + Gists + <% } %> +
+
+ <% if (result instanceof Error) { %> +
+ + <%= result.message %> +
+ <% } else { %> +
+ + +
+ <% } %> +
+
diff --git a/source/plugins/gists/tests/gists.graphql.ts b/source/plugins/gists/tests/gists.graphql.ts new file mode 100644 index 00000000000..9bfa1ce4eb7 --- /dev/null +++ b/source/plugins/gists/tests/gists.graphql.ts @@ -0,0 +1,32 @@ +import { faker, is, mock } from "@engine/utils/testing.ts" + +export default mock({ login: is.string(), privacy: is.enum(["PUBLIC", "ALL"]) }, ({ privacy }) => { + const count = { PUBLIC: 10, ALL: 20 }[privacy] + return { + entity: { + gists: { + count, + nodes: [ + ...Array.from({ length: count }, (_, i) => ({ + stargazers: faker.number.int({ max: 100 }), + forked: i % 2 === 0, + forks: { + count: faker.number.int({ max: 100 }), + }, + files: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({ + name: faker.system.fileName(), + })), + comments: { + count: faker.number.int({ max: 100 }), + }, + })), + null, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + } +}) diff --git a/source/plugins/gists/tests/list.yml b/source/plugins/gists/tests/list.yml new file mode 100644 index 00000000000..038611da3b8 --- /dev/null +++ b/source/plugins/gists/tests/list.yml @@ -0,0 +1,42 @@ +- name: supports `visibility` + plugins: + - gists: + visibility: public + handle: octocat + entity: user + processors: + - assert: + html: + select: .gists .title + match: /10 gists/i + - gists: + visibility: all + handle: octocat + entity: user + processors: + - assert: + html: + select: .gists .title + match: /20 gists/i + +- name: supports `forks` + plugins: + - gists: + forks: true + handle: octocat + entity: user + processors: + - assert: + html: + select: .gists .title + match: /including \d+ forks?/i + - gists: + forks: false + handle: octocat + entity: user + processors: + - assert: + html: + select: .gists .title + match: /!/including \d+ forks?/i + diff --git a/source/plugins/introduction/mod.ts b/source/plugins/introduction/mod.ts new file mode 100644 index 00000000000..ed741cea07f --- /dev/null +++ b/source/plugins/introduction/mod.ts @@ -0,0 +1,36 @@ +// Imports +import { is, Plugin } from "@engine/components/plugin.ts" +import { parseHandle } from "@engine/utils/github.ts" + +/** Plugin */ +export default class extends Plugin { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "🙋 Introduction" + + /** Category */ + readonly category = "github" + + /** Description */ + readonly description = "Displays user, organization or repository description" + + /** Supports */ + readonly supports = ["user", "organization", "repository"] + + /** Inputs */ + readonly inputs = is.object({}) + + /** Outputs */ + readonly outputs = is.object({ + text: is.string().describe("Introduction text"), + }) + + /** Action */ + protected async action() { + const { handle, entity } = this.context + const { entity: { text } } = await this.graphql(entity, parseHandle(handle, { entity })) + return { text } + } +} diff --git a/source/plugins/introduction/queries/organization.graphql b/source/plugins/introduction/queries/organization.graphql new file mode 100644 index 00000000000..99d309bab63 --- /dev/null +++ b/source/plugins/introduction/queries/organization.graphql @@ -0,0 +1,5 @@ +query IntroductionOrganization($login: String!) { + entity: organization(login: $login) { + text: description + } +} \ No newline at end of file diff --git a/source/plugins/introduction/queries/repository.graphql b/source/plugins/introduction/queries/repository.graphql new file mode 100644 index 00000000000..c03228d9cd5 --- /dev/null +++ b/source/plugins/introduction/queries/repository.graphql @@ -0,0 +1,5 @@ +query IntroductionRepository($owner: String!, $name: String!) { + entity: repository(owner: $owner, name: $name) { + text: description + } +} \ No newline at end of file diff --git a/source/plugins/introduction/queries/user.graphql b/source/plugins/introduction/queries/user.graphql new file mode 100644 index 00000000000..149aeae8c5e --- /dev/null +++ b/source/plugins/introduction/queries/user.graphql @@ -0,0 +1,5 @@ +query IntroductionUser($login: String!) { + entity: user(login: $login) { + text: bio + } +} \ No newline at end of file diff --git a/source/plugins/introduction/templates/classic.ejs b/source/plugins/introduction/templates/classic.ejs new file mode 100644 index 00000000000..10d2bb0d5bb --- /dev/null +++ b/source/plugins/introduction/templates/classic.ejs @@ -0,0 +1,24 @@ +
+
+ + About <%= {user:"me", organization:"us"}[entity] ?? "" %> +
+
+ <% if (result instanceof Error) { %> +
+ + <%= result.message %> +
+ <% } else { %> +
+ <%= result.text %> +
+ <% } %> +
+ +
\ No newline at end of file diff --git a/source/plugins/introduction/tests/list.yml b/source/plugins/introduction/tests/list.yml new file mode 100644 index 00000000000..8dbe7d5ba4a --- /dev/null +++ b/source/plugins/introduction/tests/list.yml @@ -0,0 +1,32 @@ +- name: supports `entity:"user"` + plugins: + - introduction: + handle: octocat + entity: user + processors: + - assert: + html: + select: .introduction .text + match: octocat + +- name: supports `entity:"organization"` + plugins: + - introduction: + handle: github + entity: organization + processors: + - assert: + html: + select: .introduction .text + match: github + +- name: supports `entity:"repository"` + plugins: + - introduction: + handle: octocat/hello-world + entity: repository + processors: + - assert: + html: + select: .introduction .text + match: octocat/hello-world \ No newline at end of file diff --git a/source/plugins/introduction/tests/organization.graphql.ts b/source/plugins/introduction/tests/organization.graphql.ts new file mode 100644 index 00000000000..a14437a525c --- /dev/null +++ b/source/plugins/introduction/tests/organization.graphql.ts @@ -0,0 +1,7 @@ +import { faker, is, mock } from "@engine/utils/testing.ts" + +export default mock({ login: is.string() }, ({ login }) => ({ + entity: { + text: `${login}: ${faker.company.catchPhrase()}`, + }, +})) diff --git a/source/plugins/introduction/tests/repository.graphql.ts b/source/plugins/introduction/tests/repository.graphql.ts new file mode 100644 index 00000000000..3e05dcb8930 --- /dev/null +++ b/source/plugins/introduction/tests/repository.graphql.ts @@ -0,0 +1,7 @@ +import { faker, is, mock } from "@engine/utils/testing.ts" + +export default mock({ owner: is.string(), name: is.string() }, ({ owner, name }) => ({ + entity: { + text: `${owner}/${name}: ${faker.company.buzzPhrase()}`, + }, +})) diff --git a/source/plugins/introduction/tests/user.graphql.ts b/source/plugins/introduction/tests/user.graphql.ts new file mode 100644 index 00000000000..ded81e267c9 --- /dev/null +++ b/source/plugins/introduction/tests/user.graphql.ts @@ -0,0 +1,7 @@ +import { faker, is, mock } from "@engine/utils/testing.ts" + +export default mock({ login: is.string() }, ({ login }) => ({ + entity: { + text: `${login}: ${faker.person.jobTitle()}`, + }, +})) diff --git a/source/plugins/lines/mod.ts b/source/plugins/lines/mod.ts new file mode 100644 index 00000000000..6f82a0301d2 --- /dev/null +++ b/source/plugins/lines/mod.ts @@ -0,0 +1,230 @@ +// Imports +import { is, parse, Plugin } from "@engine/components/plugin.ts" +import { ignored, matchPatterns, parseHandle } from "@engine/utils/github.ts" +import { delay } from "std/async/delay.ts" +import { Status } from "std/http/status.ts" +import { Graph } from "@engine/utils/graph.ts" + +/** Plugin */ +export default class extends Plugin { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "đŸ’ģ Lines of code changed" + + /** Category */ + readonly category = "github" + + /** Description */ + readonly description = "Displays the number of lines of code added and removed across repositories" + + /** Supports */ + readonly supports = ["user", "organization", "repository"] + + /** Inputs */ + readonly inputs = is.object({ + display: is.object({ + sections: is.preprocess( + (value) => [...new Set([value].flat())], + is.array(is.union([ + is.literal("graph").describe("Diff history graph"), + is.literal("repositories").describe("Diff history per repository"), + ])), + ).default(() => ["graph" as const, "repositories" as const]).describe("Displayed sections"), + repositories: is.object({ + limit: is.number().min(0).nullable().default(4).describe("Maximum number of repositories to display. Set to `null` to display all repositories"), + details: is.boolean().default(false).describe("Detailed repositories diff history"), + sort: is.union([ + is.literal("created_at").describe("Order repositories by creation date"), + is.literal("updated_at").describe("Order repositories by updated date"), + is.literal("pushed_at").describe("Order repositories by pushed date"), + is.literal("name").describe("Order repositories by name"), + is.literal("stargazers").describe("Order repositories by number of stargazers"), + is.literal("diff").describe("Order repositories by number of lines changed"), + ]).default("diff").describe("Repositories sorting method"), + }).default(() => ({})), + }).default(() => ({})).describe("Displayed content"), + repositories: is.object({ + affiliations: is.preprocess( + (value) => [...new Set([value].flat())], + is.array(is.union([ + is.literal("owner").describe("Include repositories owned by user"), + is.literal("collaborator").describe("Include repositories user has been added to as a collaborator"), + is.literal("organization_member").describe("Include repositories owned by organizations user is a member of"), + ])), + ).default(() => ["owner", "collaborator"]).describe("Repository affiliations"), + visibility: is.union([ + is.literal("public").describe("Includes public repositories only"), + is.literal("all").describe("Includes public and private repositories (n.b. still subject to token permissions)"), + ]).default("public").describe("Repository visibility"), + archived: is.boolean().default(false).describe("Include archived repositories"), + forked: is.boolean().default(false).describe("Include forked repositories"), + matching: is.preprocess((value) => [value].flat(), is.array(is.string())).default(() => ["*/*", ...ignored.repositories]).describe( + "Include repositories matching at least one of these patterns", + ), + }).default(() => ({})).describe("Repositories options"), + history: is.object({ + limit: is.number().min(0).nullable().default(1).describe("Years to keep in history. Set to `null` to keep all history"), + }).default(() => ({})).describe("History options"), + contributors: is.object({ + matching: is.preprocess((value) => [value].flat(), is.array(is.string())).default(() => ["*", ...ignored.users]).describe( + "Include contributors matching at least one of these patterns (n.b. if `entity: user`, the default value will be set to `handle` instead)", + ), + limit: is.number().min(0).nullable().default(4).describe("Number of contributors to display. Set to `null` to display all contributors"), + }).default(() => ({})).describe("Contributors options"), + fetch: is.object({ + attempts: is.number().min(0).default(30).describe("Number of retries"), + delay: is.number().min(0).default(3).describe("Delay (in seconds) before trying to fetch data again when it is not ready yet"), + }).default(() => ({})).describe("Data fetching options"), + }) + + /** Outputs */ + readonly outputs = is.object({ + repositories: is.record( + is.string().describe("Repository handle"), + is.object({ + total: is.number().min(0).describe("Total number of lines added, deleted and changed"), + added: is.number().min(0).describe("Total number of lines added"), + deleted: is.number().min(0).describe("Total number of lines deleted"), + changed: is.number().min(0).describe("Total number of lines changed"), + contributors: is.record( + is.string().describe("Contributor handle"), + is.object({ + avatar: is.string().nullable().describe("User avatar"), + total: is.number().min(0).describe("Total number of lines added, deleted and changed by user"), + added: is.number().min(0).describe("Total number of lines added by user"), + deleted: is.number().min(0).describe("Total number of lines deleted by user"), + changed: is.number().min(0).describe("Total number of lines changed by user"), + }), + ).describe("Contributors statistics (n.b. contributors are ordered by the total number of edited lines in descending order)"), + weeks: is.record( + is.string(), + is.object({ + total: is.number().min(0).describe("Total number of lines added, deleted and changed"), + added: is.number().min(0).describe("Total number of lines added"), + deleted: is.number().min(0).describe("Total number of lines deleted"), + changed: is.number().min(0).describe("Total number of lines changed"), + contributors: is.record( + is.string().describe("Contributor handle"), + is.object({ + avatar: is.string().nullable().describe("User avatar"), + total: is.number().min(0).describe("Number of lines added, deleted and changed by user"), + added: is.number().min(0).describe("Number of lines added by user"), + deleted: is.number().min(0).describe("Number of lines deleted by user"), + changed: is.number().min(0).describe("Number of lines changed by user"), + }), + ).describe("Weekly contributors statistics (n.b. contributors are ordered by the total number of edited lines in descending order)"), + }), + ).describe("Weekly statistics (n.b. weeks are ordered by date in ascending order)"), + }), + ).describe("Repositories statistics"), + }) + + /** EJS template additional rendering context */ + protected _renderctx = { Graph } + + /** Action */ + protected async action() { + const { handle } = this.context + const __contributors = this.inputs.shape.contributors.removeDefault() + const _contributors = __contributors.merge(is.object({ matching: __contributors.shape.matching.default(this.context.entity === "user" ? handle : ["*", ...ignored.users]) })).default(() => ({})) + const { repositories, fetch: fetching, history, contributors, ...args } = await parse(this.inputs.merge(is.object({ contributors: _contributors })), this.context.args) + + //Fetch repositories + const { entity: { repositories: { nodes } } } = await this.graphql("repositories", { + login: handle, + privacy: repositories.visibility === "all" ? null : repositories.visibility.toLocaleUpperCase(), + archived: repositories.archived ? null : false, + forked: repositories.forked ? null : false, + affiliations: repositories.affiliations.map((affiliation) => affiliation.toLocaleUpperCase()), + sort: ({ diff: "CREATED_AT" }[args.display.repositories.sort as string] ?? args.display.repositories.sort).toLocaleUpperCase(), + }, { paginate: true }) + + //Fetch contributors stats + const pending = [] + for (const { name } of nodes.filter(({ name }: { name: string }) => matchPatterns(repositories.matching, name))) { + pending.push((async () => { + const { owner, name: repo } = parseHandle(name, { entity: "repository" }) + for (let i = 0; i < fetching.attempts; i++) { + const { status, data } = await this.rest(this.api.repos.getContributorsStats, { owner, repo }) + if (status === Status.OK) { + return { repo: name, data } + } + if (status === Status.NoContent as typeof status) { + this.log.debug(`${name} is empty`) + return { repo: name, data: [] } + } + this.log.trace(`${name} contributors stats: status ${status}`) + if (fetching.delay) { + this.log.trace(`${name} contributors stats: retrying in ${fetching.delay}s...`) + await delay(fetching.delay * 1000) + } + } + return { repo: name, data: null } + })()) + } + const fetched = [...await Promise.allSettled(pending)].filter((result): result is Exclude => result.status === "fulfilled").map(({ value }) => value) + + //Compute lines + type lines = { total: number; added: number; deleted: number; changed: number } + type entry = { contributors: { [login: string]: lines }; weeks: { [date: string]: lines & { contributors: { [login: string]: lines & { avatar: string | null } } } } } + const result = { repositories: {} } as { repositories: { [repo: string]: lines & entry } } + for (const { repo, data } of fetched) { + if (!Array.isArray(data)) { + this.log.debug(`skipping lines from ${repo}: no data`) + continue + } + const stats = { contributors: {}, weeks: {} } as entry + for (const contributor of data.filter(({ author }) => contributors.matching.some((pattern) => matchPatterns(pattern, author?.login)))) { + const user = { login: contributor.author?.login ?? null, avatar: contributor.author?.avatar_url ?? null, total: 0, added: 0, deleted: 0, changed: 0 } + this.log.debug(`processing lines from ${repo} by ${user.login ?? "(unregistered user)"}`) + for (const { w: timestamp = NaN, a: added = 0, d: deleted = 0, c: changed = 0 } of contributor.weeks) { + if (!Number.isFinite(timestamp)) { + continue + } + if ((typeof history.limit === "number") && (new Date(timestamp * 1000).getFullYear() < new Date().getFullYear() - history.limit)) { + continue + } + const date = new Date(timestamp * 1000).toISOString().substring(0, 10) + if (!stats.weeks[date]) { + stats.weeks[date] = { total: 0, added: 0, deleted: 0, changed: 0, contributors: {} } + } + stats.weeks[date].added += added + stats.weeks[date].deleted += deleted + stats.weeks[date].changed += changed + stats.weeks[date].total += added + deleted + changed + if (user.login) { + if (!stats.weeks[date].contributors[user.login]) { + stats.weeks[date].contributors[user.login] = { avatar: user.avatar, total: 0, added: 0, deleted: 0, changed: 0 } + } + stats.weeks[date].contributors[user.login].added += added + stats.weeks[date].contributors[user.login].deleted += deleted + stats.weeks[date].contributors[user.login].changed += changed + stats.weeks[date].contributors[user.login].total += added + deleted + changed + } + user.added += added + user.deleted += deleted + user.changed += changed + user.total += added + deleted + changed + } + if (user.login) { + stats.contributors[user.login] = user + } + } + result.repositories[repo] = { + total: Object.values(stats.weeks).reduce((lines, { total }) => lines + total, 0), + added: Object.values(stats.weeks).reduce((lines, { added }) => lines + added, 0), + deleted: Object.values(stats.weeks).reduce((lines, { deleted }) => lines + deleted, 0), + changed: Object.values(stats.weeks).reduce((lines, { changed }) => lines + changed, 0), + contributors: Object.fromEntries(Object.entries(stats.contributors).sort(([_a, a], [_b, b]) => b.total - a.total).slice(0, contributors.limit ?? Infinity)), + weeks: Object.fromEntries(Object.entries(stats.weeks).sort(([a], [b]) => new Date(a).getTime() - new Date(b).getTime())), + } + } + if (args.display.repositories.sort === "diff") { + result.repositories = Object.fromEntries(Object.entries(result.repositories).sort(([_, a], [__, b]) => b.total - a.total)) + } + + return result + } +} diff --git a/source/plugins/lines/queries/repositories.graphql b/source/plugins/lines/queries/repositories.graphql new file mode 100644 index 00000000000..fd6854ee088 --- /dev/null +++ b/source/plugins/lines/queries/repositories.graphql @@ -0,0 +1,13 @@ +query Repositories($login: String!, $privacy: RepositoryPrivacy, $archived:Boolean, $forked:Boolean, $affiliations:[RepositoryAffiliation], $sort:RepositoryOrderField!, $cursor:String) { + entity:user(login: $login) { + repositories(after:$cursor, first:20, orderBy:{field:$sort, direction:DESC} privacy: $privacy, isArchived: $archived, isFork: $forked, ownerAffiliations: $affiliations) { + nodes { + name:nameWithOwner + } + pageInfo { + hasNextPage + endCursor + } + } + } +} \ No newline at end of file diff --git a/source/plugins/lines/templates/classic.ejs b/source/plugins/lines/templates/classic.ejs new file mode 100644 index 00000000000..960c2fa252d --- /dev/null +++ b/source/plugins/lines/templates/classic.ejs @@ -0,0 +1,127 @@ +
+
+ + Lines of code changed +
+
+ <% if (result instanceof Error) { %> +
+ + <%= result.message %> +
+ <% } else { %> + <% for (const section of args.display.sections) { %> + <% if (section === "repositories") { %> +
+ <% for (const [repo, {weeks, ...stats}] of Object.entries(result.repositories).slice(0, args.display.repositories.limit ?? Infinity)) { %> + <% const entries = [{repo, ...stats}, ...Object.entries(weeks).reverse().flatMap(([week, {contributors, ...stats}]) => [{week, ...stats}, ...Object.entries(contributors).map(([login, stats]) => ({login, ...stats}))])] %> + <% for (const {total, added, deleted, changed, ...entry} of entries) { if ((entry.repo)||((total)&&(args.display.repositories.details))) { %> +
+ <% if (entry.repo) { %> +
+ + <%= repo %> +
+ <% } else if (entry.week) { %> +
+ + <%= format.date(entry.week, {year:undefined}) %> to <%= format.date(new Date(new Date(entry.week).getTime() + 7 * 24 * 60 * 60 * 1000 - 1)) %> +
+ <% } else if (entry.login) { %> +
+ + <%= entry.login %> +
+ <% } %> +
+
+   +
+ <% for (let i = 1; i <= 5; i++) { %> +
+ <% } %> +
+
+ <%= `+${format.number(added+changed)}`.padStart(7) %> + <%= `-${format.number(deleted+changed)}`.padStart(7) %> +
+
+
+
+ <% } } %> + <% } %> +
+ <% } else if (section === "graph") { %> + <% + const data = {} + for (const [repo, {weeks}] of Object.entries(result.repositories)) { + for (const [week, {contributors}] of Object.entries(weeks)) { + for (const [contributor, {added, deleted, changed}] of Object.entries(contributors)) { + data[contributor] ??= {data:[]} + const date = new Date(week) + const point = data[contributor].data.find(entry => entry.date.getTime() === date.getTime()) + if (point) { + point.added += added + point.deleted += deleted + point.changed += changed + } + else + data[contributor].data.push({date, added, deleted, changed}) + } + } + } + %> + <%- Graph.diff(data, {title:`Diff history for ${handle}`, ...(entity !== "repository" ? {opacity:1} : {})}) %> + <% } %> + <% } %> + <% } %> +
+ +
\ No newline at end of file diff --git a/source/plugins/lines/tests/list.yml b/source/plugins/lines/tests/list.yml new file mode 100644 index 00000000000..5801df5da2c --- /dev/null +++ b/source/plugins/lines/tests/list.yml @@ -0,0 +1,294 @@ +- name: supports `display.sections` + plugins: + - lines: + display: + sections: graph + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .graph + count: 1= + - assert: + html: + select: .lines .entry + count: 0= + - lines: + display: + sections: repositories + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .graph + count: 0= + - assert: + html: + select: .lines .entry + count: 1>= + +- name: supports `display.repositories` + plugins: + - lines: + display: + repositories: + limit: 1 + details: true + sort: stargazers + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + count: 1= + - assert: + html: + select: .lines .entry .week + count: 1>= + +- name: supports `repositories.affiliations` + plugins: + - lines: + repositories: + affiliations: owner + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: octocat/public-repo + - assert: + html: + select: .lines .entry .repo + match: /!/(user|org)\// + - lines: + repositories: + affiliations: collaborator + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: user/public-repo + - assert: + html: + select: .lines .entry .repo + match: /!/(octocat|org)\// + - lines: + repositories: + affiliations: organization_member + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: org/public-repo + - assert: + html: + select: .lines .entry .repo + match: /!/(octocat|user)\// + - lines: + repositories: + affiliations: [owner, collaborator, organization_member] + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: /(octocat|user|org)\/public-repo/ + +- name: supports `repository.visibility` + plugins: + - lines: + repositories: + visibility: all + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: /(public|private)-repo/ + - lines: + repositories: + visibility: public + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: /!/private-repo/ + +- name: supports `repositories.archived` + plugins: + - lines: + repositories: + archived: true + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: /(?:archived-repo)?/ + - lines: + repositories: + archived: false + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: /!/archived-repo/ + +- name: supports `repository.forked` + plugins: + - lines: + repositories: + forked: true + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: /(?:forked-repo)?/ + - lines: + repositories: + forked: false + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: /!/forked-repo/ + +- name: supports `repository.matching` + plugins: + - lines: + repositories: + matching: octocat/* + affiliations: [owner, collaborator, organization_member] + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .repo + match: /octocat\// + - assert: + html: + select: .lines .entry .repo + match: /!/(user|org)\// + +- name: supports `history.limit` + plugins: + - lines: + history: + limit: null + display: + repositories: + details: true + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .week + match: /(2023|2022)/ + - lines: + history: + limit: 1 + display: + repositories: + details: true + handle: octocat + entity: user + processors: + - assert: + html: + select: .lines .entry .week + match: /2023/ + +- name: supports `contributors.matching` + plugins: + - lines: + contributors: + matching: octocat + display: + repositories: + details: true + handle: octocat/public-repo + entity: repository + processors: + - assert: + html: + select: .lines .entry .user + count: 1= + match: octocat + - lines: + contributors: + matching: "*" + limit: null + display: + repositories: + details: true + handle: octocat/public-repo + entity: repository + processors: + - assert: + html: + select: .lines .entry .user + count: 1> + +- name: supports empty repositories + plugins: + - lines: + handle: empty + entity: user + processors: + - assert: + html: + select: .lines .entry + count: 1= + +- name: supports `fetch.attempts` and `fetch.delay` + plugins: + - lines: + fetch: + attempts: 2 + delay: 0.05 + handle: retry + entity: user + processors: + - assert: + html: + select: .lines .entry + count: 1= + - processors: + - control.delay: + duration: 0.15 + - lines: + fetch: + attempts: 1 + delay: 0 + handle: retry + entity: user + processors: + - assert: + html: + select: .lines .entry + count: 0= + - processors: + - control.delay: + duration: 0.15 diff --git a/source/plugins/lines/tests/repositories.graphql.ts b/source/plugins/lines/tests/repositories.graphql.ts new file mode 100644 index 00000000000..754943882d5 --- /dev/null +++ b/source/plugins/lines/tests/repositories.graphql.ts @@ -0,0 +1,52 @@ +import { is, mock } from "@engine/utils/testing.ts" + +export default mock({ + login: is.string(), + privacy: is.enum(["PUBLIC", "PRIVATE"]).nullable(), + archived: is.boolean().nullable(), + forked: is.boolean().nullable(), + affiliations: is.array(is.enum(["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"])).nullable(), + sort: is.enum(["CREATED_AT", "UPDATED_AT", "PUSHED_AT", "NAME", "STARGAZERS"]), +}, ({ login, ...filter }) => { + const nodes = [] + if (["empty", "retry"].includes(login)) { + nodes.push({ name: `testing/${login}` }) + } else { + for (const affiliation of ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"] as const) { + if ((Array.isArray(filter.affiliations)) && (!filter.affiliations.includes(affiliation))) { + continue + } + for (const forked of [true, false]) { + if ((typeof filter.forked === "boolean") && (filter.forked !== forked)) { + continue + } + for (const archived of [true, false]) { + if ((typeof filter.archived === "boolean") && (filter.archived !== archived)) { + continue + } + for (const privacy of ["PUBLIC", "PRIVATE"]) { + if ((typeof filter.privacy === "string") && (filter.privacy !== privacy)) { + continue + } + nodes.push({ + name: `${{ OWNER: login, COLLABORATOR: "user", ORGANIZATION_MEMBER: "org" }[affiliation]}/${ + [privacy.toLocaleLowerCase(), archived ? "archived" : "", forked ? "forked" : "", "repo"].filter((value) => value).join("-") + }`, + }) + } + } + } + } + } + return { + entity: { + repositories: { + nodes, + }, + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + } +}) diff --git a/source/plugins/lines/tests/rest.ts b/source/plugins/lines/tests/rest.ts new file mode 100644 index 00000000000..a24b672637a --- /dev/null +++ b/source/plugins/lines/tests/rest.ts @@ -0,0 +1,79 @@ +import { faker, is, log, mock, Status } from "@engine/utils/testing.ts" + +let available = false +let timeout = NaN + +export default { + "/repos/{owner}/{repo}/stats/contributors": mock({ owner: is.string(), repo: is.string() }, ({ owner, repo }) => { + let status = Status.OK + if (repo === "empty") { + status = Status.NoContent + } + if (repo === "retry") { + status = available ? Status.OK : Status.Accepted + available = true + clearTimeout(timeout) + timeout = setTimeout(() => { + log.io(`${owner}/${repo}: state reset`) + available = false + }, 100) + } + const year = `${new Date().getFullYear()}` + return { + status, + data: [ + { + weeks: [ + { + w: new Date(year).getTime() / 1000, + a: faker.number.int({ min: 0, max: 10000 }), + d: faker.number.int({ min: 0, max: 10000 }), + c: faker.number.int({ min: 0, max: 10000 }), + }, + ], + author: { + login: "octocat", + avartar_url: faker.image.avatarGitHub(), + }, + }, + { + weeks: [ + { + w: new Date(year).getTime() / 1000, + a: faker.number.int({ min: 0, max: 10000 }), + d: faker.number.int({ min: 0, max: 10000 }), + c: faker.number.int({ min: 0, max: 10000 }), + }, + { + w: (new Date(year).getTime() - 7 * 24 * 60 * 60 * 1000) / 1000, + a: faker.number.int({ min: 0, max: 10000 }), + d: faker.number.int({ min: 0, max: 10000 }), + c: faker.number.int({ min: 0, max: 10000 }), + }, + { + w: new Date(`${+year - 1}`).getTime() / 1000, + a: faker.number.int({ min: 0, max: 10000 }), + d: faker.number.int({ min: 0, max: 10000 }), + c: faker.number.int({ min: 0, max: 10000 }), + }, + ], + author: { + login: "octosquid", + avartar_url: faker.image.avatarGitHub(), + }, + }, + { + weeks: [ + { + w: NaN, + a: faker.number.int({ min: 0, max: 10000 }), + d: faker.number.int({ min: 0, max: 10000 }), + c: faker.number.int({ min: 0, max: 10000 }), + }, + ], + author: null, + }, + ], + } + }), +} diff --git a/source/plugins/rss/mod.ts b/source/plugins/rss/mod.ts new file mode 100644 index 00000000000..72f008371ec --- /dev/null +++ b/source/plugins/rss/mod.ts @@ -0,0 +1,54 @@ +// Imports +import { is, parse, Plugin } from "@engine/components/plugin.ts" +import RSS from "y/rss-parser@3.13.0?pin=v133" + +/** Plugin */ +export default class extends Plugin { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "đŸ—ŧ Rss feed" + + /** Category */ + readonly category = "social" + + /** Supports */ + readonly supports = ["user", "organization", "repository"] + + /** Permissions */ + readonly permissions = ["net:all"] + + /** Description */ + readonly description = "Displays entries from a RSS feed" + + /** Inputs */ + readonly inputs = is.object({ + feed: is.string().url().default("https://news.ycombinator.com/rss").describe("RSS feed (e.g. `https://news.ycombinator.com/rss`)"), + limit: is.number().int().min(1).nullable().default(4).describe("Display limit. Set to `null` to disable"), + }) + + /** Outputs */ + readonly outputs = is.object({ + name: is.string().describe("Feed name"), + description: is.string().describe("Feed description"), + entries: is.array(is.object({ + title: is.string().describe("Entry title"), + link: is.string().url().describe("Entry link"), + date: is.date().nullable().describe("Entry date"), + })).describe("Feed entries"), + }) + + /** Action */ + protected async action() { + const { feed, limit } = await parse(this.inputs, this.context.args) + const content = await this.fetch(feed, { type: "text" }) + const { title: name, description, items } = await (new RSS()).parseString(content) + let entries = items.map(({ title, link, isoDate: date }) => ({ title, link, date: date ? new Date(date) : null })) + this.log.debug(`found ${entries.length} entries from ${feed}`) + if (limit) { + entries = entries.slice(0, limit) + } + return { name, description, entries } + } +} diff --git a/source/plugins/rss/templates/classic.ejs b/source/plugins/rss/templates/classic.ejs new file mode 100644 index 00000000000..b02d688d2ef --- /dev/null +++ b/source/plugins/rss/templates/classic.ejs @@ -0,0 +1,49 @@ +
+
+ + <%= result?.name ?? "" %> RSS feed +
+
+ <% if (result instanceof Error) { %> +
+ + <%= result.message %> +
+ <% } else { %> +
+ <% if (result.description) { %> +
+ + <%= result.description %> +
+ <% } %> + <% if (result.entries.length) { %> + <% for (const {title, date:published} of result.entries) { %> +
+ +
+
<%= title %>
+
<%= format.date(published) %>
+
+
+ <% } %> + <% } else { %> +
+ +
+ Feed is empty +
+
+ <% } %> +
+ <% } %> +
+ +
+ + diff --git a/source/plugins/rss/tests/list.yml b/source/plugins/rss/tests/list.yml new file mode 100644 index 00000000000..ce9e0b9be1b --- /dev/null +++ b/source/plugins/rss/tests/list.yml @@ -0,0 +1,21 @@ +- name: supports `feed` + plugins: + - rss: + feed: https://metrics.test/rss + limit: 1 + processors: + - assert: + html: + select: .rss .entry + count: 1= + +- name: supports `limit:null` + plugins: + - rss: + feed: https://metrics.test/rss + limit: null + processors: + - assert: + html: + select: .rss .entry + count: 1>= diff --git a/source/plugins/rss/tests/rss.http.ts b/source/plugins/rss/tests/rss.http.ts new file mode 100644 index 00000000000..32eb941ff07 --- /dev/null +++ b/source/plugins/rss/tests/rss.http.ts @@ -0,0 +1,23 @@ +import { faker, mock } from "@engine/utils/testing.ts" + +export default mock({}, () => { + return ` + + + ${faker.company.name()} + ${faker.internet.url()} + ${faker.company.catchPhrase()} + + ${faker.company.buzzPhrase()} + ${faker.internet.url()} + Sat, 16 Sep 2023 19:48:15 +0000 + + + ${faker.company.buzzPhrase()} + ${faker.internet.url()} + Sat, 16 Sep 2023 22:27:08 +0000 + + + + `.trim() +}) diff --git a/source/plugins/webscraping/dom/image.ts b/source/plugins/webscraping/dom/image.ts new file mode 100644 index 00000000000..084005bae73 --- /dev/null +++ b/source/plugins/webscraping/dom/image.ts @@ -0,0 +1,6 @@ +/// +/** Returns dimensions from selected element */ +export default function (selector: string) { + const { x, y, width, height } = document.querySelector(selector)!.getBoundingClientRect() + return { x, y, width, height } +} diff --git a/source/plugins/webscraping/dom/text.ts b/source/plugins/webscraping/dom/text.ts new file mode 100644 index 00000000000..8556e3e7374 --- /dev/null +++ b/source/plugins/webscraping/dom/text.ts @@ -0,0 +1,5 @@ +/// +/** Returns text from selected element */ +export default function (selector: string) { + return (document.querySelector(selector) as unknown as { innerText: string }).innerText +} diff --git a/source/plugins/webscraping/dom/title.ts b/source/plugins/webscraping/dom/title.ts new file mode 100644 index 00000000000..da0dd6b3b95 --- /dev/null +++ b/source/plugins/webscraping/dom/title.ts @@ -0,0 +1,5 @@ +/// +/** Returns document title */ +export default function () { + return document.title +} diff --git a/source/plugins/webscraping/mod.ts b/source/plugins/webscraping/mod.ts new file mode 100644 index 00000000000..24a48a5ea55 --- /dev/null +++ b/source/plugins/webscraping/mod.ts @@ -0,0 +1,89 @@ +// Imports +/// +import { is, parse, Plugin } from "@engine/components/plugin.ts" +import { Browser } from "@engine/utils/browser.ts" +import { delay } from "std/async/delay.ts" +import { resize } from "x/deno_image@0.0.4/mod.ts" +import { encodeBase64 } from "std/encoding/base64.ts" + +/** Plugin */ +export default class extends Plugin { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "📸 Webscraping" + + /** Category */ + readonly category = "other" + + /** Supports */ + readonly supports = ["user", "organization", "repository"] + + /** Permissions */ + readonly permissions = ["run:chrome", "write:tmp", "net:all"] + + /** Description */ + readonly description = "Screenshot or extract content from a website" + + /** Inputs */ + readonly inputs = is.object({ + title: is.string().nullable().default(null).describe("Section title. Set to `null` to use Website title (placeholder: `Web scraping`)"), + url: is.string().url().default("https://example.com").describe("Website URL (e.g. `https://example.com`)"), + select: is.string().default("body").describe("HTML query selector"), + viewport: is.object({ + width: is.number().positive().default(1280).describe("Viewport width"), + height: is.number().positive().default(1280).describe("Viewport height"), + }).default(() => ({})), + mode: is.union([ + is.literal("text").describe("Extract text from selected content"), + is.literal("image").describe("Screenshot selected content"), + ]).default("image").describe("Output format"), + wait: is.number().min(0).default(0).describe("Wait time before performing action (in seconds)"), + background: is.boolean().default(true).describe("Display background (n.b. only applies to `image` mode)"), + }) + + /** Outputs */ + readonly outputs = is.object({ + title: is.string().describe("Website title"), + content: is.string().describe("Extracted content (either raw text or base64 encoded image depending on `mode`)"), + }) + + /** Action */ + protected async action() { + if (this.context.mock) { + this.log.trace("replacing url as mock mode is enabled") + this.context.args.url = new URL("tests/example.html", import.meta.url).href + } + const { url, select: selector, mode, viewport, wait, background } = await parse(this.inputs, this.context.args) + const page = await Browser.page({ log: this.log }) + try { + await page.setViewportSize(viewport) + await page.goto(url, { waitUntil: "networkidle2" }) + if (wait) { + await delay(wait * 1000) + } + await page.waitForSelector(selector) + const result = { content: "", title: await page.evaluate("dom://title.ts") } + switch (mode) { + case "image": { + const { x, y, width, height } = await page.evaluate("dom://image.ts", { args: [selector] }) + if (!background) { + await page.setTransparentBackground() + } + const buffer = await page.screenshot({ format: "png", clip: { width, height, x, y, scale: 1 } }) as Uint8Array + const img = await resize(buffer, { height: 400 }) + result.content = `data:image/png;base64,${encodeBase64(img)}` + break + } + case "text": { + result.content = await page.evaluate("dom://text.ts", { args: [selector] }) + break + } + } + return result + } finally { + await page.close() + } + } +} diff --git a/source/plugins/webscraping/templates/classic.ejs b/source/plugins/webscraping/templates/classic.ejs new file mode 100644 index 00000000000..76670677183 --- /dev/null +++ b/source/plugins/webscraping/templates/classic.ejs @@ -0,0 +1,26 @@ +
+
+ + <%= args.title ?? result?.title ?? "Web scraping" %> +
+
+ <% if (result instanceof Error) { %> +
+ + <%= result.message %> +
+ <% } else { %> +
+ <% if (args.mode === "image") { %> + + <% } else if (args.mode === "text") { %> + <%= result.content %> + <% } %> +
+ <% } %> +
+ +
+ + diff --git a/source/plugins/webscraping/tests/example.html b/source/plugins/webscraping/tests/example.html new file mode 100644 index 00000000000..ddc0e8b54a1 --- /dev/null +++ b/source/plugins/webscraping/tests/example.html @@ -0,0 +1,33 @@ + + + example + + + + +
+
hello world
+ example +
+ + + \ No newline at end of file diff --git a/source/plugins/webscraping/tests/example.png b/source/plugins/webscraping/tests/example.png new file mode 100644 index 00000000000..71c3afcd147 Binary files /dev/null and b/source/plugins/webscraping/tests/example.png differ diff --git a/source/plugins/webscraping/tests/list.yml b/source/plugins/webscraping/tests/list.yml new file mode 100644 index 00000000000..59e59e1222e --- /dev/null +++ b/source/plugins/webscraping/tests/list.yml @@ -0,0 +1,49 @@ +- name: supports `mode:image` + plugins: + - webscraping: + url: https://metrics.test + select: img + mode: image + processors: + - assert: + html: + select: .webscraping .title + match: example + - assert: + html: + select: .webscraping img + count: 1= + +- name: supports `mode:text` + plugins: + - webscraping: + url: https://metrics.test + select: blockquote + mode: text + wait: 0.001 + processors: + - assert: + html: + select: .webscraping .title + match: example + - assert: + html: + select: .webscraping + match: hello world + +- name: supports `background:false` + plugins: + - webscraping: + url: https://metrics.test + select: blockquote + mode: image + background: false + processors: + - assert: + html: + select: .webscraping .title + match: example + - assert: + html: + select: .webscraping img + count: 1= diff --git a/source/processors/assert/mod.ts b/source/processors/assert/mod.ts new file mode 100644 index 00000000000..73471d11458 --- /dev/null +++ b/source/processors/assert/mod.ts @@ -0,0 +1,139 @@ +// Imports +import { DOMParser } from "x/deno_dom@v0.1.38/deno-dom-wasm.ts" +import { Format } from "@engine/utils/format.ts" +import { is, parse, Processor, state } from "@engine/components/processor.ts" +import { expect } from "@engine/utils/testing.ts" +import { throws } from "@engine/utils/errors.ts" + +/** Regexs */ +const regexs = { + count: /^(?\d+)(?<|<=|=?|>=|>|~)(?:(?<=~)(?\d+))?$/, + match: /^\/(?!\/)?(?[\s\S]+)\/(?\w*)$/, +} + +/** Processor */ +export default class extends Processor { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "đŸ§Ē Assertions" + + /** Category */ + readonly category = "testing" + + /** Description */ + readonly description = "Assert selection matches specified criteria" + + /** Inputs */ + readonly inputs = is.object({ + error: is.union([ + is.boolean(), + is.string(), + ]).default(false).describe( + "Assert previous content returned an error. If formatted as `/pattern/flags`, will be treated as regex (prefix with `/!` to negate match instead) (placeholder: `/foobar/i`)", + ), + mime: is.string().optional().describe("Assert mime type (e.g. `application/xml`)"), + html: is.object({ + select: is.string().default("main").describe("HTML query selector"), + match: is.string().optional().describe( + "Assert selected content match pattern. If formatted as `/pattern/flags`, will be treated as regex (prefix with `/!` to negate match instead) (placeholder: `/foobar/i`)", + ), + raw: is.boolean().default(false).describe("Use raw HTML instead of text content"), + count: is.coerce.string().regex(regexs.count).optional().describe("Assert number of elements. Supported operations are `<`, `<=`, `>`, `>=`, `=` and `~` (placeholder: `1>=`)"), + }).nullable().default(null).describe("HTML operations (only applicable when mime type is either `application/xml`, `image/svg+xml` or `text/html`)"), + }) + + /** Action */ + protected async action(state: state) { + const result = await this.piped(state) + const { error, html, mime } = await parse(this.inputs, this.context.args) + + // Error assertions + if (error) { + expect(result.result).to.be.instanceOf(Error) + if (typeof error === "string") { + const content = `${result.result}` + if (regexs.match.test(error)) { + const { negate, pattern, flags } = error.match(regexs.match)!.groups! + const regex = new RegExp(pattern, flags) + if (negate) { + expect(content).to.not.match(regex) + } else { + expect(content).to.match(regex) + } + } else { + expect(content).to.include(error) + } + } + return + } + expect(result.result).to.not.be.instanceOf(Error) + + // Mime assertions + if (mime) { + expect(result.mime).to.include(mime) + } + + // HTML assertions + if ((!html) || (!/(application\/xml)|(image\/svg\+xml)|(text\/html)/.test(result.mime))) { + if (html) { + throws(`html selection is only supported for mime type "application/xml", "image/svg+xml" or "text/html" (got "${result.mime}")`) + } + return + } + const { select, match, raw, count } = html + const document = new DOMParser().parseFromString(Format.html(result.content), "text/html") + const selected = [...document?.querySelectorAll(select) ?? []] + + // HTML content count assertions + if (typeof count === "string") { + const captured = count.match(regexs.count)!.groups! + const n = Number(captured.n) + const m = Number(captured.m) + const op = captured.op || "=" + switch (op) { + case "<": + expect(selected.length).to.be.below(n) + break + case "<=": + expect(selected.length).to.be.at.most(n) + break + case ">": + expect(selected.length).to.be.above(n) + break + case ">=": + expect(selected.length).to.be.at.least(n) + break + case "=": + expect(selected.length).to.be.equal(n) + break + case "~": + expect(selected.length).to.be.within(n - m, n + m) + break + } + if ((op === "=") && (n === 0)) { + return + } + } + expect(selected.length).to.be.at.least(1, "expected at least one element to be present") + + // HTML content matching assertions + for (const element of selected) { + if (typeof match === "string") { + const content = raw ? ((element.parentElement ?? element) as unknown as { innerHTML: string }).innerHTML : element.textContent + if (regexs.match.test(match)) { + const { negate, pattern, flags } = match.match(regexs.match)!.groups! + const regex = new RegExp(pattern, flags) + if (negate) { + expect(content).to.not.match(regex) + } else { + expect(content).to.match(regex) + } + } else { + expect(content).to.include(match) + } + } + } + } +} diff --git a/source/processors/assert/tests/list.yml b/source/processors/assert/tests/list.yml new file mode 100644 index 00000000000..42582c38ceb --- /dev/null +++ b/source/processors/assert/tests/list.yml @@ -0,0 +1,114 @@ +- name: assert preview result is error + plugins: + - processors: + - control.error: + message: expected error + fatal: false + logs: none + - assert: + error: /expected error/ + - processors: + - control.error: + message: expected error + fatal: false + logs: none + - assert: + error: /!/unexpected error/ + - processors: + - control.error: + message: expected error + fatal: false + logs: none + - assert: + error: expected error + +- name: assert mime type match + plugins: + - processors: + - inject.content: + content: foo + mime: application/octet-stream + - assert: + mime: application/octet-stream + +- name: assert selected inner html match content + plugins: + - processors: + - inject.content: + content: hello world + - assert: + html: + select: .test + match: hello world + - assert: + html: + select: .test + match: /hello world/ + - assert: + html: + select: .test + match: /!/foo bar/ + +- name: assert selected raw html match content + plugins: + - processors: + - inject.content: + content: + - assert: + html: + select: .test + match: /data-test="true"/ + raw: true + - assert: + html: + select: html + match: // + raw: true + +- name: assert selected html elements match count + plugins: + - processors: + - inject.content: + content: + - assert: + html: + select: .a + count: 1= + - assert: + html: + select: .a + count: 0~1 + - assert: + html: + select: .a + count: 1>= + - assert: + html: + select: .a + count: 0> + - assert: + html: + select: .a + count: 1<= + - assert: + html: + select: .a + count: 2< + - assert: + html: + select: .b + count: 0 + +- name: html selection cannot be used with non html-like mime type + plugins: + - processors: + - inject.content: + content: foo + mime: application/octet-stream + - assert: + html: + select: .foo + fatal: false + logs: none + - assert: + error: /is only supported for mime type/ \ No newline at end of file diff --git a/source/processors/control.delay/mod.ts b/source/processors/control.delay/mod.ts new file mode 100644 index 00000000000..265b4660eb9 --- /dev/null +++ b/source/processors/control.delay/mod.ts @@ -0,0 +1,31 @@ +// Imports +import { is, parse, Processor } from "@engine/components/processor.ts" +import { delay } from "std/async/delay.ts" + +/** Processor */ +export default class extends Processor { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "⏰ Delay" + + /** Category */ + readonly category = "control" + + /** Description */ + readonly description = "Delay execution" + + /** Inputs */ + readonly inputs = is.object({ + duration: is.number().min(0).default(0).describe("Time to wait (in seconds)"), + }) + + /** Action */ + protected async action() { + const { duration } = await parse(this.inputs, this.context.args) + this.log.info(`waiting ${duration} seconds as requested`) + await delay(duration * 1000) + this.log.info("resuming") + } +} diff --git a/source/processors/control.delay/tests/list.yml b/source/processors/control.delay/tests/list.yml new file mode 100644 index 00000000000..e617e8f2fd5 --- /dev/null +++ b/source/processors/control.delay/tests/list.yml @@ -0,0 +1,7 @@ +- name: delay + plugins: + - processors: + - control.delay: + duration: 0.1 + - inject.content: + content: ok \ No newline at end of file diff --git a/source/processors/control.error/mod.ts b/source/processors/control.error/mod.ts new file mode 100644 index 00000000000..30cd5e21330 --- /dev/null +++ b/source/processors/control.error/mod.ts @@ -0,0 +1,29 @@ +// Imports +import { is, parse, Processor } from "@engine/components/processor.ts" +import { throws } from "@engine/utils/errors.ts" + +/** Processor */ +export default class extends Processor { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "🚩 Throw an error" + + /** Category */ + readonly category = "control" + + /** Description */ + readonly description = "Throw an error" + + /** Inputs */ + readonly inputs = is.object({ + message: is.string().default('Error thrown by "control.error"').describe("Error message"), + }) + + /** Action */ + protected async action() { + const { message } = await parse(this.inputs, this.context.args) + throws(message, { unrecoverable: true }) + } +} diff --git a/source/processors/control.error/tests/list.yml b/source/processors/control.error/tests/list.yml new file mode 100644 index 00000000000..861f7c92cc5 --- /dev/null +++ b/source/processors/control.error/tests/list.yml @@ -0,0 +1,9 @@ +- name: throws error + plugins: + - processors: + - control.error: + message: expected error + fatal: false + logs: none + - assert: + error: /expected error/i \ No newline at end of file diff --git a/source/processors/inject.content/mod.ts b/source/processors/inject.content/mod.ts new file mode 100644 index 00000000000..6eca38ff4c8 --- /dev/null +++ b/source/processors/inject.content/mod.ts @@ -0,0 +1,36 @@ +// Imports +import { is, parse, Processor, state } from "@engine/components/processor.ts" + +/** Processor */ +export default class extends Processor { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "🔩 Inject raw content" + + /** Category */ + readonly category = "injector" + + /** Description */ + readonly description = "Inject custom raw content" + + /** Supports */ + readonly supports = ["application/xml"] + + /** Inputs */ + readonly inputs = is.object({ + content: is.string().describe("Content to inject (placeholder: `
foo
`) (textarea)"), + mime: is.string().optional().describe("Resulting mime type (e.g. `application/xml`). Leave empty to reuse previous mime type"), + }) + + /** Action */ + protected async action(state: state) { + const result = await this.piped(state) + const { content, mime } = await parse(this.inputs, this.context.args) + if (mime) { + result.mime = mime + } + result.content = `${result.content}${content}` + } +} diff --git a/source/processors/inject.content/tests/list.yml b/source/processors/inject.content/tests/list.yml new file mode 100644 index 00000000000..f5f88d62a6f --- /dev/null +++ b/source/processors/inject.content/tests/list.yml @@ -0,0 +1,18 @@ +- name: can inject raw content + plugins: + - processors: + - inject.content: + content: hello world + - assert: + html: + select: main + match: hello world + +- name: can change mime type + plugins: + - processors: + - inject.content: + content: hello world + mime: text/plain + - assert: + mime: text/plain \ No newline at end of file diff --git a/source/processors/inject.script/dom/script.ts b/source/processors/inject.script/dom/script.ts new file mode 100644 index 00000000000..e851126002d --- /dev/null +++ b/source/processors/inject.script/dom/script.ts @@ -0,0 +1,6 @@ +/// +/** Executes script */ +export default async function (script: string) { + await new Function("document", `return (async () => { ${script} })()`)(document) + return document.querySelector("main")!.innerHTML +} diff --git a/source/processors/inject.script/mod.ts b/source/processors/inject.script/mod.ts new file mode 100644 index 00000000000..93bd58bc32c --- /dev/null +++ b/source/processors/inject.script/mod.ts @@ -0,0 +1,46 @@ +// Imports +import { is, parse, Processor, state } from "@engine/components/processor.ts" +import { Browser } from "@engine/utils/browser.ts" +import { Format } from "@engine/utils/format.ts" + +/** Processor */ +export default class extends Processor { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "🔩 Inject JavaScript" + + /** Category */ + readonly category = "injector" + + /** Description */ + readonly description = "Inject and execute custom JavaScript" + + /** Supports */ + readonly supports = ["application/xml", "image/svg+xml", "text/html"] + + /** Permissions */ + readonly permissions = ["run:chrome", "write:tmp"] + + /** Inputs */ + readonly inputs = is.object({ + script: is.string().describe('JavaScript to inject and execute (placeholder: `document.querySelector("svg")`) (textarea)'), + }) + + /** Action */ + protected async action(state: state) { + const result = await this.piped(state) + const { script } = await parse(this.inputs, this.context.args) + const page = await Browser.page({ log: this.log }) + try { + this.log.trace("processing content in browser") + await page.setContent(Format.html(result.content)) + await page.waitForNavigation({ waitUntil: "load" }) + this.log.trace(`injecting script: ${script}`) + result.content = await page.evaluate("dom://script.ts", { args: [script] }) + } finally { + await page.close() + } + } +} diff --git a/source/processors/inject.script/tests/list.yml b/source/processors/inject.script/tests/list.yml new file mode 100644 index 00000000000..f92ba89a2e1 --- /dev/null +++ b/source/processors/inject.script/tests/list.yml @@ -0,0 +1,25 @@ +- name: can inject and execute raw script + plugins: + - processors: + - inject.content: + content: + - inject.script: + script: | + document.querySelector(".test").textContent = "hello world"; + - assert: + html: + select: .test + match: hello world + +- name: throws on invalid script + plugins: + - processors: + - inject.content: + content: + - inject.script: + script: | + throw new Error("expected error") + fatal: false + logs: none + - assert: + error: /expected error/i diff --git a/source/processors/inject.style/dom/style.ts b/source/processors/inject.style/dom/style.ts new file mode 100644 index 00000000000..d40907b02b1 --- /dev/null +++ b/source/processors/inject.style/dom/style.ts @@ -0,0 +1,19 @@ +/// +/** Inject scoped CSS */ +export default function (scope: string, style: string) { + // List CSS rules + const virtual = document.implementation.createHTMLDocument(""), tag = document.createElement("style") + tag.textContent = style + virtual.body.appendChild(tag) + let styled = "" + for (const { selectorText: selectors, cssText: rule } of Object.values(tag.sheet!.cssRules) as CSSStyleRule[]) { + const parsed = selectors.split(",").map((selector: string) => selector.trim()) as string[] + styled += rule.replace(selectors, parsed.flatMap((selector) => [`[${scope}]${selector}`, `[${scope}] ${selector}`]).join(",")) + } + tag.textContent = styled + // Inject CSS + const main = document.querySelector("main")! + main.appendChild(tag) + document.querySelectorAll("main > *:not(style)").forEach((element) => element.setAttribute(scope, "true")) + return main.innerHTML +} diff --git a/source/processors/inject.style/mod.ts b/source/processors/inject.style/mod.ts new file mode 100644 index 00000000000..f2bdf006440 --- /dev/null +++ b/source/processors/inject.style/mod.ts @@ -0,0 +1,48 @@ +// Imports +/// +import { is, parse, Processor, state } from "@engine/components/processor.ts" +import { Browser } from "@engine/utils/browser.ts" +import { Format } from "@engine/utils/format.ts" + +/** Processor */ +export default class extends Processor { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "🔩 Inject CSS" + + /** Category */ + readonly category = "injector" + + /** Description */ + readonly description = "Inject custom CSS" + + /** Supports */ + readonly supports = ["application/xml"] + + /** Permissions */ + readonly permissions = ["run:chrome", "write:tmp"] + + /** Inputs */ + readonly inputs = is.object({ + style: is.string().describe("CSS to inject (placeholder: `.foo { color: skyblue; }`) (textarea)"), + }) + + /** Action */ + protected async action(state: state) { + const result = await this.piped(state) + const { style } = await parse(this.inputs, this.context.args) + const page = await Browser.page({ log: this.log }) + try { + this.log.trace("processing content in browser") + await page.setContent(Format.html(result.content)) + await page.waitForNavigation({ waitUntil: "load" }) + const scope = `inject-style-${crypto.randomUUID().slice(-12)}` + this.log.trace(`injecting style: ${style}`) + result.content = await page.evaluate("dom://style.ts", { args: [scope, style] }) + } finally { + await page.close() + } + } +} diff --git a/source/processors/inject.style/tests/list.yml b/source/processors/inject.style/tests/list.yml new file mode 100644 index 00000000000..9aa8cbc8a12 --- /dev/null +++ b/source/processors/inject.style/tests/list.yml @@ -0,0 +1,18 @@ +- name: can inject style + plugins: + - processors: + - inject.content: + content: + - inject.style: + style: | + .test { color: blue; } + - assert: + html: + select: .test + match: //i + raw: true + - assert: + html: + select: .test + match: /\[inject-style-.*?\]\s+.test.*color:\s+blue/i + raw: true diff --git a/source/processors/optimize.css/mod.ts b/source/processors/optimize.css/mod.ts new file mode 100644 index 00000000000..8710bb37d49 --- /dev/null +++ b/source/processors/optimize.css/mod.ts @@ -0,0 +1,42 @@ +// Imports +import { Processor, state } from "@engine/components/processor.ts" +import { PurgeCSS } from "y/purgecss@5.0.0?pin=v133" +import { DOMParser } from "x/deno_dom@v0.1.38/deno-dom-wasm.ts" +import { Format } from "@engine/utils/format.ts" +import { minify } from "y/csso@5.0.5?pin=v133" +import { unescape } from "y/html-escaper@3.0.3?pin=v133" + +/** Processor */ +export default class extends Processor { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "🧹 Optimize CSS" + + /** Category */ + readonly category = "optimizer" + + /** Description */ + readonly description = "Optimize CSS by removing unused styles and minifying" + + /** Supports */ + readonly supports = ["application/xml", "image/svg+xml"] + + /** Action */ + protected async action(state: state) { + const result = await this.piped(state) + const document = new DOMParser().parseFromString(Format.html(result.content), "text/html")! + for (const style of document.querySelectorAll('style[data-optimizable="true"]')) { + this.log.trace("purging css") + const raw = unescape(style.textContent) + style.textContent = "" + const purged = await new PurgeCSS().purge({ content: [{ raw: document.querySelector("main")!.innerHTML, extension: "html" }], css: [{ raw }] }) + this.log.trace("optimizing css") + const optimized = minify(purged.map(({ css }) => css).join("\n")).css + style.textContent = optimized + ;(style as unknown as { removeAttribute(attr: string): void }).removeAttribute("data-optimizable") + } + result.content = document.querySelector("main")!.innerHTML + } +} diff --git a/source/processors/optimize.css/tests/list.yml b/source/processors/optimize.css/tests/list.yml new file mode 100644 index 00000000000..a3ff22cf670 --- /dev/null +++ b/source/processors/optimize.css/tests/list.yml @@ -0,0 +1,49 @@ +- name: can optimize css + plugins: + - processors: + - inject.content: + content: | + + + Hello + World + + + + - optimize.css: + - assert: + html: + select: svg style + match: /\.a\{.*\}/i + - assert: + html: + select: svg style + match: /!/\.b\{.*\}/i + +- name: does not edit non-optimizable styles + plugins: + - processors: + - inject.content: + content: | + + + Hello + World + + + + - optimize.css: + - assert: + html: + select: svg style + match: /\.a { color:\ red; \}/i + - assert: + html: + select: svg style + match: /\.b \{ color:\ blue; \}/i diff --git a/source/processors/optimize.svg/mod.ts b/source/processors/optimize.svg/mod.ts new file mode 100644 index 00000000000..433b80aef1d --- /dev/null +++ b/source/processors/optimize.svg/mod.ts @@ -0,0 +1,50 @@ +// Imports +import { Processor, state } from "@engine/components/processor.ts" +import { optimize } from "y/svgo@3.0.2?pin=v133" +import { throws } from "@engine/utils/errors.ts" + +/** Processor */ +export default class extends Processor { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "🧹 Optimize SVG" + + /** Category */ + readonly category = "optimizer" + + /** Description */ + readonly description = "Optimize SVG to reduce file size and improve rendering speed" + + /** Supports */ + readonly supports = ["image/svg+xml"] + + /** Action */ + protected async action(state: state) { + const result = await this.piped(state) + const { data: optimized } = optimize(result.content, { + plugins: [ + { + name: "preset-default", + params: { + //Force CSS style consistency + overrides: { + inlineStyles: false, + removeViewBox: false, + }, + }, + }, + //Additional cleanup + "cleanupListOfValues", + "removeRasterImages", + "removeScriptElement", + ], + multipass: true, + }) + if (!optimized) { + throws("Failed to optimize SVG (no data returned)") + } + result.content = optimized + } +} diff --git a/source/processors/optimize.svg/tests/list.yml b/source/processors/optimize.svg/tests/list.yml new file mode 100644 index 00000000000..181dc8bb3b2 --- /dev/null +++ b/source/processors/optimize.svg/tests/list.yml @@ -0,0 +1,24 @@ +- name: can optimize svg + plugins: + - processors: + - inject.content: + content: + mime: image/svg+xml + - optimize.svg: + - assert: + html: + select: main + match: /<.svg>/i + raw: true + +- name: throws on invalid svg + plugins: + - processors: + - inject.content: + content: + mime: image/svg+xml + - optimize.svg: + fatal: false + logs: none + - assert: + error: /failed to optimize svg/i diff --git a/source/processors/optimize.xml/mod.ts b/source/processors/optimize.xml/mod.ts new file mode 100644 index 00000000000..2ecc4b95acf --- /dev/null +++ b/source/processors/optimize.xml/mod.ts @@ -0,0 +1,27 @@ +// Imports +import { Processor, state } from "@engine/components/processor.ts" +import xmlFormat from "y/xml-formatter@3.5.0?pin=v133" + +/** Processor */ +export default class extends Processor { + /** Import meta */ + static readonly meta = import.meta + + /** Name */ + readonly name = "🧹 Optimize XML" + + /** Category */ + readonly category = "optimizer" + + /** Description */ + readonly description = "Format XML to improve diff readability and size" + + /** Supports */ + readonly supports = ["application/xml", "image/svg+xml"] + + /** Action */ + protected async action(state: state) { + const result = await this.piped(state) + result.content = xmlFormat(result.content, { indentation: " ", collapseContent: true, ignoredPaths: [] }) + } +} diff --git a/source/processors/optimize.xml/tests/list.yml b/source/processors/optimize.xml/tests/list.yml new file mode 100644 index 00000000000..eeed1ed632f --- /dev/null +++ b/source/processors/optimize.xml/tests/list.yml @@ -0,0 +1,22 @@ +- name: can optimize xml + plugins: + - processors: + - inject.content: + content: + - optimize.xml: + - assert: + html: + select: main + match: /<.span>/i + raw: true + +- name: throws on invalid xml + plugins: + - processors: + - inject.content: + content: +
+
+ + + + Metrics + + + + +
+ +
+
+ + + + + + +
+ + +
+
+
+
+
+ +
+
+

Craft your profile infographics

+

the way you want

+
+ + Customize them with a large choice options +
+
+ + Export and share them anywhere +
+
+ + Using free and open-sourced code +
+
+ +
+
+ + +
+
+ + Metrics v4 is still in development and some features may not be available yet. + Please refer to #1573 for more informations +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + \ No newline at end of file diff --git a/source/run/serve/static/opengraph.png b/source/run/serve/static/opengraph.png new file mode 100644 index 00000000000..8dd369da5fe Binary files /dev/null and b/source/run/serve/static/opengraph.png differ diff --git a/source/run/serve/static/styles.css b/source/run/serve/static/styles.css new file mode 100644 index 00000000000..9954a2e67cf --- /dev/null +++ b/source/run/serve/static/styles.css @@ -0,0 +1,63 @@ +.markdown p, .markdown blockquote, .markdown pre { + margin-bottom: 8px; +} +.markdown blockquote { + border-left: 4px solid #52525b; + color: #a1a1aa; + padding-left: 8px; +} +.markdown code .hljs-comment { + color: #8b949e; +} +.markdown code .hljs-keyword { + color: #ff7b72; +} +.markdown code .hljs-string { + color: #a5d6ff; +} +.markdown code .hljs-attr { + color: #79c0ff; +} +.markdown code .hljs-title { + color: #d2a8ff; +} +.markdown code .hljs-addition { + color: #aff5b4; + background-color: #033a16; +} +.markdown code .hljs-deletion { + color: #ffdcd7; + background-color: #67060c; +} +.compat-messages > div { + margin-bottom: 8px; + padding: 8px; + border-radius: 4px; + border: 1px solid #52525b; +} +.compat-messages > .info { + border-color: #0fa5ea; + background-color: #0fa5ea10; +} +.compat-messages > .warning { + border-color: #78350f; + background-color: #78350f10; +} +.compat-messages > .error { + border-color: #430c11; + background-color: #430c1110; +} +.compat-messages > .unimplemented { + border-color: #f8bf27; + background-color: #f8bf2710; +} +.compat-messages code:not([class^="language-"]) { + background-color: #00000090; + border-radius: 4px; + padding: 0px 4px; +} +.compat-messages pre { + background-color: #00000090; + border-radius: 4px; + padding: 4px 8px; +} \ No newline at end of file