<repo_name>75asa/deno-github-contributions-api<file_sep>/color_scheme.ts
import { hexStrToHexNum } from "./utils.ts";
import {
  CONTRIBUTION_LEVELS,
  ContributionLevelName,
  isValidContributionLevelName,
} from "./contributions.ts";

const COLOR_SCHEMES = {
  // by [williambelle/github-contribution-color-graph](https://github.com/williambelle/github-contribution-color-graph)
  github: ["#eeeeee", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
  halloween: ["#eeeeee", "#fdf156", "#ffc722", "#ff9711", "#04001b"],
  amber: ["#eeeeee", "#ffecb3", "#ffd54f", "#ffb300", "#ff6f00"],
  blue: ["#eeeeee", "#bbdefb", "#64b5f6", "#1e88e5", "#0d47a1"],
  bluegrey: ["#eeeeee", "#cfd8dc", "#90a4ae", "#546e7a", "#263238"],
  brown: ["#eeeeee", "#d7ccc8", "#a1887f", "#6d4c41", "#3e2723"],
  cyan: ["#eeeeee", "#b2ebf2", "#4dd0e1", "#00acc1", "#006064"],
  deeporange: ["#eeeeee", "#ffccbc", "#ff8a65", "#f4511e", "#bf360c"],
  deeppurple: ["#eeeeee", "#d1c4e9", "#9575cd", "#5e35b1", "#311b92"],
  green: ["#eeeeee", "#c8e6c9", "#81c784", "#43a047", "#1b5e20"],
  grey: ["#eeeeee", "#e0e0e0", "#9e9e9e", "#616161", "#212121"],
  indigo: ["#eeeeee", "#c5cae9", "#7986cb", "#3949ab", "#1a237e"],
  lightblue: ["#eeeeee", "#b3e5fc", "#4fc3f7", "#039be5", "#01579b"],
  lightgreen: ["#eeeeee", "#dcedc8", "#aed581", "#7cb342", "#33691e"],
  lime: ["#eeeeee", "#f0f4c3", "#dce775", "#c0ca33", "#827717"],
  orange: ["#eeeeee", "#ffe0b2", "#ffb74d", "#fb8c00", "#e65100"],
  pink: ["#eeeeee", "#f8bbd0", "#f06292", "#e91e63", "#880e4f"],
  purple: ["#eeeeee", "#e1bee7", "#ba68c8", "#8e24aa", "#4a148c"],
  red: ["#eeeeee", "#ffcdd2", "#e57373", "#e53935", "#b71c1c"],
  teal: ["#eeeeee", "#b2dfdb", "#4db6ac", "#00897b", "#004d40"],
  yellowMd: ["#eeeeee", "#fff9c4", "#fff176", "#ffd835", "#f57f17"],
  unicorn: ["#eeeeee", "#6dc5fb", "#f6f68c", "#8affa4", "#f283d1"],
  summer: ["#eeeeee", "#eae374", "#f9d62e", "#fc913a", "#ff4e50"],
  sunset: ["#eeeeee", "#fed800", "#ff6f01", "#fd2f24", "#811d5e"],
  moon: ["#eeeeee", "#6bcdff", "#00a1f3", "#48009a", "#4f2266"],
  psychedelic: ["#eeeeee", "#faafe1", "#fb6dcc", "#fa3fbc", "#ff00ab"],
  yellow: ["#eeeeee", "#d7d7a2", "#d4d462", "#e0e03f", "#ffff00"],

  // by kawarimidoll
  gameboy: ["#eeeeee", "#ccdc5f", "#91a633", "#606520", "#2c370b"],
};
type ColorSchemeName = keyof typeof COLOR_SCHEMES;

const isValidColorSchemeName = (name?: string): name is ColorSchemeName =>
  !!name && Object.hasOwn(COLOR_SCHEMES, name);

const randomColorScheme = () => {
  const values = Object.values(COLOR_SCHEMES);
  return values[(Math.random() * values.length) << 0];
};

const getColorScheme = (name = "github") => {
  if (name != "random" && !isValidColorSchemeName(name)) {
    throw new Error(
      `'${name}' is invalid color scheme name! Choose from: ${
        Object.keys(COLOR_SCHEMES)
      },random`,
    );
  }

  const hexStrColors = name === "random"
    ? randomColorScheme()
    : COLOR_SCHEMES[name];

  const hexNumColors = hexStrColors.map((color) => hexStrToHexNum(color));

  const getByLevel = (levelName?: ContributionLevelName) =>
    hexNumColors[
      isValidContributionLevelName(levelName)
        ? CONTRIBUTION_LEVELS[levelName]
        : 0
    ];

  return { hexStrColors, hexNumColors, getByLevel };
};

export { COLOR_SCHEMES, getColorScheme, isValidColorSchemeName };
export type { ColorSchemeName };
<file_sep>/health_check.ts
import { ky } from "./deps.ts";

const prefixUrl = "https://github-contributions-api.deno.dev";
const k = ky.create({ prefixUrl });
try {
  console.log("Root");
  await k("").text();

  console.log("User");
  await k("kawarimidoll").text();

  console.log("Text");
  await k("kawarimidoll.text").text();

  console.log("Json");
  await k("kawarimidoll.json").text();

  console.log("Term");
  await k("kawarimidoll.term").text();

  console.log("Svg");
  await k("kawarimidoll.svg").text();

  console.log("Parameters");
  await k("kawarimidoll.svg", {
    searchParams: {
      scheme: "random",
      "no-total": true,
      bg: "123abc",
    },
  }).text();

  console.log("System all green!");
} catch (error) {
  console.error(`${error}`);
  Deno.exit(1);
}
<file_sep>/utils_test.ts
import { assertEquals } from "./deps.ts";
import {
  confirmHex,
  convertToSixChars,
  hexStrToHexNum,
  hexStrToRgbObj,
} from "./utils.ts";

Deno.test("confirmHex", () => {
  assertEquals(confirmHex("#123456"), "#123456");
  assertEquals(confirmHex("123456"), "123456");
  assertEquals(confirmHex("#12A"), "#12A");
  assertEquals(confirmHex("12A"), "12A");
  assertEquals(confirmHex("#12345"), "eee");
  assertEquals(confirmHex("12345"), "eee");
  assertEquals(confirmHex("#12"), "eee");
  assertEquals(confirmHex("12"), "eee");
  assertEquals(confirmHex("hex"), "eee");
});

Deno.test("convertToSixChars", () => {
  assertEquals(convertToSixChars("#123456"), "123456");
  assertEquals(convertToSixChars("123456"), "123456");
  assertEquals(convertToSixChars("#12A"), "1122AA");
  assertEquals(convertToSixChars("12A"), "1122AA");
  assertEquals(convertToSixChars("12"), "eeeeee");
  assertEquals(convertToSixChars("hex"), "eeeeee");
});

Deno.test("hexStrToRgbObj", () => {
  assertEquals(hexStrToRgbObj("#123456"), { r: 18, g: 52, b: 86 });
  assertEquals(hexStrToRgbObj("123456"), { r: 18, g: 52, b: 86 });
  assertEquals(hexStrToRgbObj("#12A"), { r: 17, g: 34, b: 170 });
  assertEquals(hexStrToRgbObj("12A"), { r: 17, g: 34, b: 170 });
  assertEquals(hexStrToRgbObj("12"), { r: 238, g: 238, b: 238 });
  assertEquals(hexStrToRgbObj("hex"), { r: 238, g: 238, b: 238 });
});

Deno.test("hexStrToHexNum", () => {
  assertEquals(hexStrToHexNum("#123456"), 0x123456);
  assertEquals(hexStrToHexNum("123456"), 0x123456);
  assertEquals(hexStrToHexNum("#12A"), 0x1122aa);
  assertEquals(hexStrToHexNum("12A"), 0x1122aa);
});
<file_sep>/mod.ts
// constants and functions
export {
  CONTRIBUTION_LEVELS,
  getContributions,
  isValidContributionLevelName,
} from "./contributions.ts";

export { COLOR_SCHEMES, getColorScheme } from "./color_scheme.ts";

export {
  confirmHex,
  convertToSixChars,
  hexStrToHexNum,
  hexStrToRgbObj,
} from "./utils.ts";

// types and interfaces
export type {
  ContributionDay,
  ContributionLevelName,
} from "./contributions.ts";

export type { ColorSchemeName } from "./color_scheme.ts";
<file_sep>/utils.ts
const defaultPixelColor = "eee";

const confirmHex = (str: string, defaultColor = defaultPixelColor) =>
  /^#?([0-9a-f]{3}){1,2}$/i.test(str) ? str : defaultColor;

const convertToSixChars = (str: string, defaultColor = defaultPixelColor) =>
  confirmHex(str, defaultColor).replace(
    /^#?(.*)$/,
    (_, hex) => (hex.length == 3) ? hex.replace(/./g, "$&$&") : hex,
  );

const hexStrToRgbObj = (color: string, defaultColor = defaultPixelColor) =>
  Object.fromEntries(
    (convertToSixChars(color || defaultColor).match(/../g) ?? []).map((
      c,
      i,
    ) => ["rgb".charAt(i), parseInt("0x" + c)]),
  );

const hexStrToHexNum = (color: string, defaultColor = defaultPixelColor) =>
  parseInt("0x" + convertToSixChars(color || defaultColor));

export { confirmHex, convertToSixChars, hexStrToHexNum, hexStrToRgbObj };
<file_sep>/README.md
# deno-github-contributions-api

[![ci](https://github.com/kawarimidoll/deno-github-contributions-api/workflows/ci/badge.svg)](.github/workflows/ci.yml)
[![deno deploy](https://img.shields.io/badge/deno-deploy-blue?logo=deno)](https://github-contributions-api.deno.dev)
[![deno.land](https://img.shields.io/badge/deno-%5E1.13.0-green?logo=deno)](https://deno.land)
[![vr scripts](https://badges.velociraptor.run/flat.svg)](https://velociraptor.run)
[![LICENSE](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)

Get your GitHub contributions data powered by Deno!

![gif](https://github.com/kawarimidoll/deno-github-contributions-api/raw/main/t-rec.gif)

## Usage

### as API

In your terminal:

```
$ curl https://github-contributions-api.deno.dev
# Then follow the messages...
```

Of course, you can access the endpoint from the web browser:
https://github-contributions-api.deno.dev

### as deno module

In your deno script file:

```ts
import { getContributions } from "https://github.com/kawarimidoll/deno-github-contributions-api/raw/main/mod.ts";

const username = "your-github-username";
const token = "<KEY>";

const contributions = await getContributions(username, token);

console.log(contributions.toTerm({ scheme: "random" }));
```

You can see an example in
[main.ts](https://github.com/kawarimidoll/deno-github-contributions-api/blob/main/main.ts)

The personal access token which has a "read:user" scope is required.

Generate your token from this page: https://github.com/settings/tokens/new

## Extra

If you are using [GitHub CLI](https://github.com/cli/cli), you can call this API
from [gh-graph](https://github.com/kawarimidoll/gh-graph).

<!-- ## TODO               -->
<!-- - add more tests      -->
<!-- - add Month/Day names -->

---

```ts
if (this.repo.isAwesome || this.repo.isHelpful) {
  star(this.repo);
}
```

<!-- this part is inspired by https://github.com/bhumijgupta/Deno-news-cli -->
<file_sep>/color_scheme_test.ts
import { assert, assertEquals, assertThrows } from "./deps.ts";
import {
  COLOR_SCHEMES,
  getColorScheme,
  isValidColorSchemeName,
} from "./color_scheme.ts";

Deno.test("getColorScheme", () => {
  const correctScheme = ["#eeeeee", "#9be9a8", "#40c463", "#30a14e", "#216e39"];

  const scheme1 = getColorScheme("github");
  assertEquals(scheme1.hexStrColors, correctScheme);

  const scheme2 = getColorScheme();
  assertEquals(scheme2.hexStrColors, correctScheme);

  assertEquals(scheme2.getByLevel("NONE"), 0xeeeeee);
  assertEquals(scheme2.getByLevel("FIRST_QUARTILE"), 0x9be9a8);
  assertEquals(scheme2.getByLevel(), 0xeeeeee);

  const scheme3 = getColorScheme("random");
  assert(Object.values(COLOR_SCHEMES).includes(scheme3.hexStrColors));

  assertThrows(() => {
    getColorScheme("123456");
  });
});

Deno.test("isValidColorSchemeName", () => {
  assert(isValidColorSchemeName("github"));
  assert(isValidColorSchemeName("unicorn"));
  assert(!isValidColorSchemeName(""));
  assert(!isValidColorSchemeName("nothub"));
});
<file_sep>/contributions.ts
import { getColorScheme } from "./color_scheme.ts";
import { bgRgb24, h, ky, rgb24, stringWidth } from "./deps.ts";
import { confirmHex, convertToSixChars } from "./utils.ts";

interface ContributionDay {
  contributionCount: number;
  contributionLevel: ContributionLevelName;
  date: string;
  color: string;
}

const CONTRIBUTION_LEVELS = {
  NONE: 0,
  FIRST_QUARTILE: 1,
  SECOND_QUARTILE: 2,
  THIRD_QUARTILE: 3,
  FOURTH_QUARTILE: 4,
};
type ContributionLevelName = keyof typeof CONTRIBUTION_LEVELS;

const isValidContributionLevelName = (
  name?: string,
): name is ContributionLevelName =>
  !!name && Object.hasOwn(CONTRIBUTION_LEVELS, name);

const getContributionCalendar = async (
  userName: string,
  token: string,
) => {
  if (!userName || !token) {
    throw new Error("Missing required arguments");
  }

  const query = `
 query($userName:String!) {
   user(login: $userName){
     contributionsCollection {
       contributionCalendar {
         totalContributions
         weeks {
           contributionDays {
             color
             contributionCount
             contributionLevel
             date
           }
         }
       }
     }
   }
 }
 `;
  const variables = `
 {
   "userName": "${userName}"
 }
 `;

  const json = { query, variables };
  const url = "https://api.github.com/graphql";
  const { data } = await ky.post(url, {
    headers: { Authorization: `Bearer ${token}` },
    json,
  }).json();

  const contributionCalendar = data?.user?.contributionsCollection
    ?.contributionCalendar;

  if (
    !contributionCalendar || !Object.hasOwn(contributionCalendar, "weeks") ||
    !Object.hasOwn(contributionCalendar, "totalContributions")
  ) {
    throw new Error("Could not get contributions data");
  }

  const { weeks, totalContributions }: {
    weeks: { contributionDays: ContributionDay[] }[];
    totalContributions: number;
  } = contributionCalendar;

  const contributions = weeks.map((week) => week.contributionDays);

  return { contributions, totalContributions };
};

const totalMsg = (totalNum: number): string =>
  totalNum + " contributions in the last year\n";

const moreContributionDay = (a: ContributionDay, b: ContributionDay) =>
  a.contributionCount > b.contributionCount ? a : b;

const getMaxContributionDay = (
  contributions: ContributionDay[][],
): ContributionDay =>
  contributions.reduce(
    (max, week) =>
      moreContributionDay(
        max,
        week.reduce(
          (maxInWeek, current) => moreContributionDay(maxInWeek, current),
          week[0],
        ),
      ),
    contributions[0][0],
  );

const contributionsToJson = (
  contributions: ContributionDay[][],
  totalContributions: number,
  {
    flat = false,
  } = {},
) =>
  JSON.stringify({
    contributions: flat ? contributions.flat() : contributions,
    totalContributions,
  });

const contributionsToTerm = (
  contributions: ContributionDay[][],
  totalContributions: number,
  {
    noTotal = false,
    noLegend = false,
    scheme = "github",
    pixel = "■",
    invert = false,
  } = {},
) => {
  const pixelWidth = stringWidth(pixel);
  if (pixelWidth > 2) {
    // width == 2 is ok
    // like as "[]", "草", " "
    throw new Error(`Pixel '${pixel}' is too long. Max width of pixel is 2.`);
  }

  const colorScheme = getColorScheme(scheme);

  const total = !noTotal ? totalMsg(totalContributions) : "";

  // 10 is length of 'Less  More'
  // 5 is count of colored pixels as legend
  const legendOffset = " ".repeat(
    (contributions.length - 5) * pixelWidth - 10,
  );

  const legend = !noLegend
    ? legendOffset +
      "Less " + colorScheme.hexNumColors.map((color) =>
        invert ? bgRgb24(pixel, color) : rgb24(pixel, color)
      ).join("") + " More\n"
    : "";

  const grass = (day?: ContributionDay) =>
    day?.contributionLevel
      ? invert
        ? bgRgb24(pixel, colorScheme.getByLevel(day?.contributionLevel))
        : rgb24(pixel, colorScheme.getByLevel(day?.contributionLevel))
      : "";

  return total +
    contributions[0].reduce(
      (acc, _, i) =>
        acc + contributions.map((row) => grass(row[i])).join("") + "\n",
      "",
    ) + legend;
};

const contributionsToText = (
  contributions: ContributionDay[][],
  totalContributions: number,
  maxContributionDay: ContributionDay,
  {
    noTotal = false,
  } = {},
) => {
  const total = !noTotal ? totalMsg(totalContributions) : "";

  const pad = String(maxContributionDay.contributionCount).length;

  return total +
    contributions[0].reduce(
      (acc, _, i) =>
        acc + contributions.map((row) =>
          `${row[i]?.contributionCount ?? ""}`.padStart(pad)
        ).join(",") +
        "\n",
      "",
    );
};

const contributionsToSvg = (
  contributions: ContributionDay[][],
  totalContributions: number,
  {
    noTotal = false,
    noLegend = false,
    scheme = "github",
    fontColor = "000",
    frame = "none",
    bg = "none",
  } = {},
): string => {
  const svgID = "deno-github-contributions-graph";
  const rectSize = 10;
  const rectSpan = 3;
  const rectRadius = 2;
  const rectStep = rectSize + rectSpan;

  const weekCounts = 53;
  const dayCounts = 7;

  const topPadding = noTotal ? 0 : 1;
  const bottomPadding = noLegend ? 0 : 1;

  const width = rectStep * (weekCounts + 2) - rectSpan;
  const height = rectStep * (dayCounts + 2 + topPadding + bottomPadding) -
    rectSpan;

  const offset = { x: rectStep, y: rectStep * (topPadding + 1) };

  // the left top position of the 5 pixels of legend
  const legendPos = {
    x: width - rectStep * 10 + rectSpan,
    y: offset.y + rectStep * dayCounts + rectSpan,
  };

  const styles = `#${svgID} .pixel {
    width: ${rectSize}px;
    height: ${rectSize}px;
    rx: ${rectRadius}px;
    ry: ${rectRadius}px;
    stroke: rgba(27,31,35,0.06);
    stroke-width: 2px;
  }
  #${svgID} text {
    font-family: monospace;
    font-size: ${rectSize * 1.5}px;
    fill: #${convertToSixChars(fontColor, "000")};
  }
  `;

  try {
    const colorScheme = getColorScheme(scheme);

    const rect = (x: number, y: number, {
      contributionLevel = "",
      date = "",
      contributionCount = 0,
    }): string =>
      contributionLevel == null ? "" : h("rect", {
        class: `pixel ${contributionLevel}`,
        x: x * rectStep,
        y: y * rectStep,
        "data-date": date,
        "data-count": contributionCount,
      }, h("title", `${date}: ${contributionCount}`));

    frame = confirmHex(frame, "none");
    const stroke = frame === "none" ? frame : "#" + convertToSixChars(frame);
    bg = confirmHex(bg, "none");
    const fill = bg === "none" ? bg : "#" + convertToSixChars(bg);

    return h(
      "svg",
      { width, height, xmlns: "http://www.w3.org/2000/svg", id: svgID },
      h(
        "style",
        styles,
        ...Object.entries(CONTRIBUTION_LEVELS).map(([k, v]) =>
          `#${svgID} .${k} { fill: ${colorScheme.hexStrColors[v]}; }`
        ),
      ),
      h("rect", { width, height, stroke, "stroke-width": "2px", fill }),
      noTotal ? "" : h(
        "g",
        h(
          "text",
          { transform: `translate(${offset.x}, ${offset.y - rectSpan * 2})` },
          totalMsg(totalContributions),
        ),
      ),
      h(
        "g",
        { transform: `translate(${offset.x}, ${offset.y})` },
        contributions.map((column, i) =>
          column.map((pixel, j) => rect(i, j, pixel)).join("")
        ).join(""),
      ),
      noLegend ? "" : h(
        "g",
        { transform: `translate(${legendPos.x}, ${legendPos.y})` },
        h(
          "text",
          {
            transform: `translate(-${rectStep * 1}, ${rectSize * 1})`,
            "text-anchor": "end",
          },
          "Less",
        ),
        Object.keys(CONTRIBUTION_LEVELS).map((levelName, idx) =>
          rect(idx, 0, { contributionLevel: levelName })
        ).join(""),
        h(
          "text",
          {
            transform: `translate(${rectStep * 5 + rectSize}, ${rectSize * 1})`,
          },
          "More",
        ),
      ),
    );
  } catch (error) {
    return h(
      "svg",
      { width, height, xmlns: "http://www.w3.org/2000/svg", id: svgID },
      h(
        "text",
        { y: height },
        `${error}`,
      ),
    );
  }
};

const getContributions = async (
  userName: string,
  token: string,
) => {
  const { contributions, totalContributions } = await getContributionCalendar(
    userName,
    token,
  );

  const maxContributionDay = getMaxContributionDay(contributions);

  const toJson = ({ flat = false } = {}) =>
    contributionsToJson(contributions, totalContributions, { flat });

  const toTerm = (
    {
      noTotal = false,
      noLegend = false,
      scheme = "github",
      pixel = "■",
      invert = false,
    } = {},
  ) =>
    contributionsToTerm(contributions, totalContributions, {
      noTotal,
      noLegend,
      scheme,
      pixel,
      invert,
    });

  const toText = (
    {
      noTotal = false,
    } = {},
  ) =>
    contributionsToText(contributions, totalContributions, maxContributionDay, {
      noTotal,
    });
  const toSvg = (
    {
      noTotal = false,
      noLegend = false,
      scheme = "github",
      fontColor = "000",
      frame = "none",
      bg = "none",
    } = {},
  ) =>
    contributionsToSvg(contributions, totalContributions, {
      noTotal,
      noLegend,
      scheme,
      fontColor,
      frame,
      bg,
    });

  return {
    contributions,
    totalContributions,
    maxContributionDay,
    toJson,
    toTerm,
    toText,
    toSvg,
  };
};

export {
  CONTRIBUTION_LEVELS,
  contributionsToJson,
  contributionsToSvg,
  contributionsToTerm,
  contributionsToText,
  getContributionCalendar,
  getContributions,
  getMaxContributionDay,
  isValidContributionLevelName,
  moreContributionDay,
  totalMsg,
};
export type { ContributionDay, ContributionLevelName };
<file_sep>/deps.ts
import ky from "https://cdn.skypack.dev/ky@0.28.5?dts";

import testdouble from "https://esm.sh/testdouble@3.16.1/dist/testdouble.js";

import stringWidth from "https://cdn.skypack.dev/string-width@5.0.0?dts";

import { bgRgb24, rgb24 } from "https://deno.land/std@0.106.0/fmt/colors.ts";

import {
  assert,
  assertEquals,
  assertRejects,
  assertThrows,
} from "https://deno.land/std@0.106.0/testing/asserts.ts";

import { Env } from "https://deno.land/x/env@v2.2.0/env.js";
const env = new Env();

import { tag as h } from "https://deno.land/x/markup_tag@0.2.3/mod.ts";

export {
  assert,
  assertEquals,
  assertRejects,
  assertThrows,
  bgRgb24,
  env,
  h,
  ky,
  rgb24,
  stringWidth,
  testdouble,
};
<file_sep>/main.ts
import { getContributions } from "./contributions.ts";
import { env } from "./deps.ts";

const username = "kawarimidoll";
const token = env.require("GH_READ_USER_TOKEN");

const contributions = await getContributions(username, token);

// console.log(contributions.toJson());
console.log(contributions.toTerm({ scheme: "random" }));
// console.log(contributions.toText());
// console.log(contributions.toTerm({ invert: true, pixel: " " }));
<file_sep>/contributions_test.ts
import {
  assert,
  assertEquals,
  assertRejects,
  assertThrows,
  ky,
  testdouble,
} from "./deps.ts";
import {
  ContributionDay,
  contributionsToJson,
  contributionsToSvg,
  contributionsToTerm,
  contributionsToText,
  getContributionCalendar,
  getContributions,
  getMaxContributionDay,
  isValidContributionLevelName,
  moreContributionDay,
  totalMsg,
} from "./contributions.ts";

const {
  contributions,
  totalContributions,
}: {
  contributions: ContributionDay[][];
  totalContributions: number;
} = JSON.parse(
  await Deno.readTextFile("./resources/tests/example_contributions.json"),
);

const weeks = contributions.map((week) => ({ contributionDays: week }));

const max: ContributionDay = {
  contributionCount: 32,
  contributionLevel: "FOURTH_QUARTILE",
  date: "2021-03-22",
  color: "#216e39",
};

Deno.test("contributionsToJson", () => {
  assertEquals(
    contributionsToJson(contributions, totalContributions),
    JSON.stringify({ contributions, totalContributions }),
  );

  assertEquals(
    contributionsToJson(contributions, totalContributions, { flat: true }),
    JSON.stringify({ contributions: contributions.flat(), totalContributions }),
  );
});

Deno.test("contributionsToSvg", async () => {
  const resultToSvg = await Deno.readTextFile(
    "./resources/tests/to_svg.svg",
  );
  const resultToSvgWithParams = await Deno.readTextFile(
    "./resources/tests/to_svg_bg_font_frame_scheme.svg",
  );
  assertEquals(
    contributionsToSvg(contributions, totalContributions),
    resultToSvg,
  );
  assertEquals(
    contributionsToSvg(contributions, totalContributions, {
      bg: "786688",
      fontColor: "#d7f07b",
      frame: "#f03153",
      scheme: "amber",
    }),
    resultToSvgWithParams,
  );
});

Deno.test("contributionsToTerm", async () => {
  const resultToTerm = await Deno.readTextFile(
    "./resources/tests/to_term_github.text",
  );
  const resultToTermUnicorn = await Deno.readTextFile(
    "./resources/tests/to_term_unicorn.text",
  );
  const resultToTermNoTotal = await Deno.readTextFile(
    "./resources/tests/to_term_no_total.text",
  );
  const resultToTermNoLegend = await Deno.readTextFile(
    "./resources/tests/to_term_no_legend.text",
  );
  const resultToTermPixelX = await Deno.readTextFile(
    "./resources/tests/to_term_pixel_x.text",
  );
  const resultToTermInvert = await Deno.readTextFile(
    "./resources/tests/to_term_invert.text",
  );
  assertEquals(
    contributionsToTerm(contributions, totalContributions),
    resultToTerm,
  );
  assertEquals(
    contributionsToTerm(contributions, totalContributions, {
      noTotal: false,
      noLegend: false,
      scheme: "github",
      pixel: "■",
      invert: false,
    }),
    resultToTerm,
  );
  assertEquals(
    contributionsToTerm(contributions, totalContributions, {
      scheme: "unicorn",
    }),
    resultToTermUnicorn,
  );
  assertEquals(
    contributionsToTerm(contributions, totalContributions, { noTotal: true }),
    resultToTermNoTotal,
  );
  assertEquals(
    contributionsToTerm(contributions, totalContributions, { noLegend: true }),
    resultToTermNoLegend,
  );
  assertEquals(
    contributionsToTerm(contributions, totalContributions, { pixel: "x" }),
    resultToTermPixelX,
  );
  assertThrows(
    () => {
      contributionsToTerm(contributions, totalContributions, { pixel: "xxx" });
    },
    Error,
  );
  assertEquals(
    contributionsToTerm(contributions, totalContributions, { invert: true }),
    resultToTermInvert,
  );
});

Deno.test("contributionsToText", async () => {
  const resultToText = await Deno.readTextFile(
    "./resources/tests/to_text.text",
  );
  const resultToTextNoTotal = await Deno.readTextFile(
    "./resources/tests/to_text_no_total.text",
  );
  assertEquals(
    contributionsToText(contributions, totalContributions, max),
    resultToText,
  );
  assertEquals(
    contributionsToText(contributions, totalContributions, max, {
      noTotal: true,
    }),
    resultToTextNoTotal,
  );
});

Deno.test("getContributions", async () => {
  testdouble.replace(
    ky,
    "post",
    (_: string) => ({
      json: () => ({ data: null }),
    }),
  );

  assertRejects(
    () => {
      return getContributions("a", "a");
    },
    Error,
    "Could not get contributions data",
  );

  testdouble.replace(
    ky,
    "post",
    () => ({
      json: () => ({
        data: {
          user: {
            contributionsCollection: {
              contributionCalendar: {
                weeks,
                totalContributions,
              },
            },
          },
        },
      }),
    }),
  );

  const obj = await getContributions("a", "a");
  assert(obj);
  assertEquals(obj.contributions, contributions);
  assertEquals(obj.totalContributions, totalContributions);
  assertEquals(obj.maxContributionDay, max);
  assert(obj.toJson());
  assert(obj.toTerm());
  assert(obj.toText());
});

Deno.test("getContributionCalendar", async () => {
  assertRejects(
    () => {
      return getContributionCalendar("userName", "");
    },
    Error,
    "Missing required arguments",
  );
  assertRejects(
    () => {
      return getContributionCalendar("", "token");
    },
    Error,
    "Missing required arguments",
  );

  testdouble.replace(
    ky,
    "post",
    (_: string) => ({
      json: () => ({ data: null }),
    }),
  );

  assertRejects(
    () => {
      return getContributionCalendar("a", "a");
    },
    Error,
    "Could not get contributions data",
  );

  testdouble.replace(
    ky,
    "post",
    () => ({
      json: () => ({
        data: {
          user: {
            contributionsCollection: {
              contributionCalendar: {
                // weeks: [{ contributionDays: [max] }],
                weeks,
                totalContributions,
              },
            },
          },
        },
      }),
    }),
  );

  assertEquals(
    await getContributionCalendar("a", "a"),
    { contributions, totalContributions },
  );
});

Deno.test("getMaxContributionDay", () => {
  assertEquals(getMaxContributionDay(contributions), max);
});

Deno.test("isValidContributionLevelName", () => {
  assert(isValidContributionLevelName("NONE"));
  assert(isValidContributionLevelName("FIRST_QUARTILE"));
  assert(isValidContributionLevelName("SECOND_QUARTILE"));
  assert(isValidContributionLevelName("THIRD_QUARTILE"));
  assert(isValidContributionLevelName("FOURTH_QUARTILE"));
  assert(!isValidContributionLevelName(""));
  assert(!isValidContributionLevelName("none"));
});

Deno.test("moreContributionDay", () => {
  const a: ContributionDay = {
    contributionCount: 10,
    contributionLevel: "FIRST_QUARTILE",
    date: "2000-01-01",
    color: "#eeeeee",
  };
  const b: ContributionDay = {
    contributionCount: 3,
    contributionLevel: "FIRST_QUARTILE",
    date: "2000-01-01",
    color: "#eeeeee",
  };

  assertEquals(moreContributionDay(a, b), a);
});

Deno.test("totalMsg", () => {
  assertEquals(totalMsg(10), "10 contributions in the last year\n");
});
<file_sep>/server.ts
/// <reference path="./deploy.d.ts" />

import { getContributions } from "./contributions.ts";
import { env } from "./deps.ts";

// cache one hour
const CACHE_MAX_AGE = 3600;

function getPathExtension(request: Request): string {
  const { pathname } = new URL(request.url);
  const split = pathname.split(".");
  return split.length > 1 ? split[split.length - 1] : "";
}

async function handleRequest(request: Request) {
  const { pathname, searchParams, host } = new URL(request.url);

  if (pathname === "/") {
    return [
      "Welcome to deno-github-contributions-api!",
      `Access to ${host}/[username] to get your contributions data.`,
    ].join("\n");
  }

  const paths = pathname.split("/");
  if (paths.length > 2) {
    throw new Error(
      `'${request.url}' is invalid path. Access to ${host}/[username].`,
    );
  }
  const username = paths[1].replace(/\..*$/, "");
  const ext = getPathExtension(request);

  const contributions = await getContributions(
    username,
    env.require("GH_READ_USER_TOKEN"),
  );

  const scheme = searchParams.get("scheme") ?? "github";
  const pixel = searchParams.get("pixel") ?? undefined;
  const noTotal = searchParams.get("no-total") == "true";
  const noLegend = searchParams.get("no-legend") == "true";
  const flat = searchParams.get("flat") == "true";
  const invert = searchParams.get("invert") == "true";
  const fontColor = searchParams.get("font-color") ?? "#000";
  const frame = searchParams.get("frame") ?? "none";
  const bg = searchParams.get("bg") ?? "none";

  if (ext === "json") {
    return contributions.toJson({ flat });
  }

  if (ext === "term") {
    return contributions.toTerm({ scheme, pixel, noTotal, noLegend, invert });
  }

  if (ext === "text") {
    return contributions.toText({ noTotal });
  }

  if (ext === "svg") {
    return contributions.toSvg({
      scheme,
      noTotal,
      noLegend,
      frame,
      bg,
      fontColor,
    });
  }

  return [
    `${contributions.totalContributions} contributions in the last year.`,
    "",
    `Use extensions like as '${host}/${username}.text'.`,
    " - .json : return data as a json",
    " - .term : return data as a colored pixels graph (works in the terminal with true color)",
    " - .text : return data as a table-styled text",
    " - .svg  : return data as a svg image",
    "",
    "You can use other parameters. Each of them works on specific extensions.",
    " - no-total=true      : remove total contributions count (term/text/svg)",
    " - no-legend=true     : remove legend (term/svg)",
    " - invert=true        : change the background colors instead of the foreground colors (term)",
    " - flat=true          : return contributions as one-dimensional array (json)",
    " - scheme=[name]      : use specific color scheme (term/svg)",
    " - pixel=[char]       : use the character as pixels, URL encoding is required (term)",
    " - frame=[color]      : use the color as a frame of image (svg)",
    " - bg=[color]         : use the color as a background of image (svg)",
    " - font-color=[color] : use the color as a font color (svg)",
    "",
    "Color parameters allows hex color string without # like '123abc'.",
  ].join("\n");
}

addEventListener("fetch", async (event) => {
  const ext = getPathExtension(event.request);
  const type = {
    json: "application/json",
    svg: "image/svg+xml",
  }[ext] || "text/plain";
  const headers = {
    "Content-Type": `${type}; charset=utf-8`,
    "Cache-Control": `public, max-age=${CACHE_MAX_AGE}`,
  };

  try {
    const body = await handleRequest(event.request);
    event.respondWith(new Response(body, { headers }));
  } catch (error) {
    console.error(error);

    const body = ext == "json"
      ? JSON.stringify({ error: `${error}` })
      : `${error}`;
    event.respondWith(
      new Response(body, {
        status: 400,
        headers,
      }),
    );
  }
});
