Initial commit

This commit is contained in:
Yuri Sizov
2023-03-28 16:06:15 +02:00
commit 91df762984
29 changed files with 3588 additions and 0 deletions

13
.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.{css}]
indent_style = space
indent_size = 2

57
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Continuous integration
on:
push:
branches: [ master ]
schedule:
# Run every hour at 10 minutes past the hour mark.
# The slight offset is there to try and avoid the high load times.
- cron: '10 * * * *'
# Make sure jobs cannot overlap (e.g. one from push and one from schedule).
concurrency:
group: pages-ci
cancel-in-progress: true
jobs:
build:
name: Build and deploy to GitHub Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 16.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build the static content using npm
run: npm run build
- name: Fetch artifact data (master)
run: npm run compose-db -- branch:master
env:
GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Archive production artifacts
uses: actions/upload-artifact@v3
with:
name: web-static
path: out
- name: Deploy to GitHub Pages 🚀
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: gh-pages
folder: out
# Configure the commit author.
git-config-name: 'Godot Organization'
git-config-email: '<>'
# Don't keep the history.
single-commit: true

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Project folders.
node_modules/
out/
temp/
logs/
# Development environments.
.idea/
.vscode/

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
# MIT License
Copyright © 2023-present Godot Engine contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

43
README.md Normal file
View File

@@ -0,0 +1,43 @@
# Godot Commit Artifacts
This project is provided for Godot Engine users and contributors to
easily and reliably get links to the CI build artifacts for the main
development branches. While these artifacts are not suitable for
production use, they can be used for testing and early feature
adoption.
Live website: https://godotengine.github.io/godot-commit-artifacts/
## Contributing
This project is written in JavaScript and is built using Node.JS. HTML and CSS are
used for the presentation. The end result of the build process is completely static
and can be server from any web server, no Node.JS required.
Front-end is designed in a reactive manner using industry standard Web Components
(powered by `lit-element`). This provides native browser support, and results in a
small overhead from the build process.
To build the project locally you need to have Node.JS installed (12.x and newer
should work just fine).
This project uses GitHub's GraphQL API. To fetch live data you need to generate
a [personal OAuth token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token).
You can supply your token to the scripts using the `GRAPHQL_TOKEN` environment
variable. Note, that if you don't have member access to the organization, you
may not be able to access all the information used when generating the database.
1. Clone or download the project.
2. From the project root run `npm install` or `yarn` to install dependencies.
3. Run `npm run build` or `yarn run build` to build the pages.
4. Run `npm run compose-db` or `yarn run compose-db` to fetch the data from GitHub.
5. Serve the `out/` folder with your method of choice (e.g. using Python 3:
`python -m http.server 8080 -d ./out`).
`rollup` is used for browser packing of scripts and copying of static assets. The
data fetching script is plain JavaScript with `node-fetch` used to polyfill
`fetch()`-like API.
## License
This project is provided under the [MIT License](LICENSE.md).

41
build/posthtml-include.js Normal file
View File

@@ -0,0 +1,41 @@
import fs from "fs";
import path from "path";
import parser from "posthtml-parser";
export default function(options) {
options = options || {};
options.root = options.root || './';
options.encoding = options.encoding || 'utf-8';
return function posthtmlInclude(tree) {
tree.match({ tag: 'include' }, function(node) {
if (!node.attrs.src) {
return {
tag: false,
content: null
};
}
const src = path.resolve(options.root, node.attrs.src);
const source = fs.readFileSync(src, options.encoding);
const subtree = parser(source);
subtree.match = tree.match;
const content = source.indexOf('include') !== -1? posthtmlInclude(subtree): subtree;
if (tree.messages) {
tree.messages.push({
type: "dependency",
file: src
});
}
return {
tag: false,
content: content
};
});
return tree;
};
};

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Nothing to see here</title>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,31 @@
import { promises as fs } from 'fs';
import posthtml from 'posthtml';
import include from './posthtml-include';
import { green } from 'colorette';
export default function(options = {}) {
return {
name: 'posthtml',
buildEnd: async () => {
if (!options.src || !options.dest) {
return;
}
const html = await fs.readFile(options.src, { encoding: 'utf-8' });
const plugins = [
include({
root: './src'
})
];
const result = await posthtml(plugins).process(html);
try {
await fs.unlink(options.dest);
} catch (exc) { }
await fs.writeFile(options.dest, result.html, { encoding: 'utf-8' });
console.log(green(`written html template ${options.dest}`))
}
};
}

507
compose-db.js Normal file
View File

@@ -0,0 +1,507 @@
const fs = require('fs').promises;
const fsConstants = require('fs').constants;
const fetch = require('node-fetch');
const ExitCodes = {
"RequestFailure": 1,
"ParseFailure": 2,
"ExecFailure": 3,
"IOFailure": 4,
};
const LogFormat = {
"Raw": 0,
"JSON": 1,
};
const API_DELAY_MSEC = 1500;
const API_MAX_RETRIES = 5;
const API_RATE_LIMIT = `
rateLimit {
limit
cost
nodeCount
remaining
resetAt
}
`;
class DataFetcher {
constructor(data_owner, data_repo) {
this.data_owner = data_owner;
this.data_repo = data_repo;
this.repo_ssh_path = `git@github.com:${data_owner}/${data_repo}.git`;
this.api_rest_path = `https://api.github.com/repos/${data_owner}/${data_repo}`;
this.api_repository_id = `owner:"${data_owner}" name:"${data_repo}"`;
}
async _logResponse(data, name, format = LogFormat.JSON) {
try {
await ensureDir("./logs");
let filename = `./logs/${name}`;
let fileContent = "" + data;
if (format === LogFormat.JSON) {
filename = `./logs/${name}.json`;
fileContent = JSON.stringify(data, null, 4);
}
await fs.writeFile(filename, fileContent, {encoding: "utf-8"});
} catch (err) {
console.error(" Error saving log file: " + err);
}
}
_handleResponseErrors(queryID, res) {
console.warn(` Failed to get data from '${queryID}'; server responded with ${res.status} ${res.statusText}`);
const retry_header = res.headers.get("Retry-After");
if (retry_header) {
console.log(` Retry after: ${retry_header}`);
}
}
_handleDataErrors(data) {
if (typeof data["errors"] === "undefined") {
return;
}
console.warn(` Server handled the request, but there were errors:`);
data.errors.forEach((item) => {
console.log(` [${item.type}] ${item.message}`);
});
}
async delay(msec) {
return new Promise(resolve => setTimeout(resolve, msec));
}
async fetchGithub(query, retries = 0) {
const init = {};
init.method = "POST";
init.headers = {};
init.headers["Content-Type"] = "application/json";
if (process.env.GRAPHQL_TOKEN) {
init.headers["Authorization"] = `token ${process.env.GRAPHQL_TOKEN}`;
} else if (process.env.GITHUB_TOKEN) {
init.headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}`;
}
init.body = JSON.stringify({
query,
});
let res = await fetch("https://api.github.com/graphql", init);
let attempt = 0;
while (res.status !== 200 && attempt < retries) {
attempt += 1;
console.log(` Failed with status ${res.status}, retrying (${attempt}/${retries})...`);
// GitHub API is flaky, so we add an extra delay to let it calm down a bit.
await this.delay(API_DELAY_MSEC);
res = await fetch("https://api.github.com/graphql", init);
}
return res;
}
async fetchGithubRest(query) {
const init = {};
init.method = "GET";
init.headers = {};
init.headers["Content-Type"] = "application/json";
if (process.env.GRAPHQL_TOKEN) {
init.headers["Authorization"] = `token ${process.env.GRAPHQL_TOKEN}`;
} else if (process.env.GITHUB_TOKEN) {
init.headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}`;
}
return await fetch(`${this.api_rest_path}${query}`, init);
}
async checkRates() {
try {
const query = `
query {
${API_RATE_LIMIT}
}
`;
const res = await this.fetchGithub(query);
if (res.status !== 200) {
this._handleResponseErrors(this.api_repository_id, res);
process.exitCode = ExitCodes.RequestFailure;
return;
}
const data = await res.json();
await this._logResponse(data, "_rate_limit");
this._handleDataErrors(data);
const rate_limit = data.data["rateLimit"];
console.log(` [$${rate_limit.cost}][${rate_limit.nodeCount}] Available API calls: ${rate_limit.remaining}/${rate_limit.limit}; resets at ${rate_limit.resetAt}`);
} catch (err) {
console.error(" Error checking the API rate limits: " + err);
process.exitCode = ExitCodes.RequestFailure;
return;
}
}
async fetchRuns(branchName) {
try {
const query = `
query {
${API_RATE_LIMIT}
repository (${this.api_repository_id}) {
object (expression: "${branchName}") {
... on Commit {
history(first: 10) {
edges {
node {
...CommitData
}
}
}
}
}
}
}
fragment CommitData on Commit {
oid
committedDate
messageHeadline
checkSuites(first: 20) {
edges {
node {
...CheckSuiteData
}
}
}
}
fragment CheckSuiteData on CheckSuite {
databaseId
url
status
conclusion
createdAt
updatedAt
workflowRun {
databaseId
workflow {
databaseId
name
}
}
}
`;
console.log(` Requesting workflow runs data for commits in "${branchName}".`);
const res = await this.fetchGithub(query, API_MAX_RETRIES);
if (res.status !== 200) {
this._handleResponseErrors(this.api_repository_id, res);
process.exitCode = ExitCodes.RequestFailure;
return [];
}
const data = await res.json();
await this._logResponse(data, `data_runs_${branchName}`);
this._handleDataErrors(data);
const repository = data.data["repository"];
const run_data = mapNodes(repository.object["history"]);
const rate_limit = data.data["rateLimit"];
console.log(` [$${rate_limit.cost}][${rate_limit.nodeCount}] Retrieved ${run_data.length} commits and their runs.`);
console.log(` --`);
return run_data;
} catch (err) {
console.error(" Error fetching workflow runs data: " + err);
process.exitCode = ExitCodes.RequestFailure;
return [];
}
}
async fetchArtifacts(runId) {
try {
const query = `/actions/runs/${runId}/artifacts`;
const res = await this.fetchGithubRest(query);
if (res.status !== 200) {
this._handleResponseErrors(query, res);
process.exitCode = ExitCodes.RequestFailure;
return [];
}
const data = await res.json();
await this._logResponse(data, `data_artifacts_${runId}`);
this._handleDataErrors(data);
const artifacts_data = data.artifacts;
console.log(` [$0] Retrieved ${artifacts_data.length} artifacts for '${runId}'; processing...`);
return artifacts_data;
} catch (err) {
console.error(" Error fetching artifact data: " + err);
process.exitCode = ExitCodes.RequestFailure;
return [];
}
}
}
class DataProcessor {
constructor() {
this.commits = [];
this.checks = {};
this.runs = {};
this.artifacts = {};
}
processRuns(runsRaw) {
try {
runsRaw.forEach((item) => {
// Compile basic information about a commit.
let commit = {
"hash": item.oid,
"title": item.messageHeadline,
"committed_date": item.committedDate,
"checks": [],
};
const checkSuites = mapNodes(item.checkSuites);
checkSuites.forEach((checkItem) => {
// Compile basic information about a check suite.
let check = {
"check_id": checkItem.databaseId,
"check_url": checkItem.url,
"status": checkItem.status,
"conclusion": checkItem.conclusion,
"created_at": checkItem.createdAt,
"updated_at": checkItem.updatedAt,
"workflow": null,
};
if (checkItem.workflowRun) {
const runItem = checkItem.workflowRun;
let run = {
"name": runItem.workflow.name,
"workflow_id": runItem.workflow.databaseId,
"run_id": runItem.databaseId,
"artifacts": [],
};
this.runs[run.run_id] = run;
check.workflow = run.run_id;
}
this.checks[check.check_id] = check;
commit.checks.push(check.check_id);
});
this.commits.push(commit);
});
} catch (err) {
console.error(" Error parsing pull request data: " + err);
process.exitCode = ExitCodes.ParseFailure;
}
}
processArtifacts(runId, artifactsRaw) {
try {
artifactsRaw.forEach((item) => {
let artifact = {
"id": item.id,
"name": item.name,
"size": item.size_in_bytes,
"created_at": item.created_at,
"updated_at": item.upadted_at,
"expires_at": item.expires_at,
};
this.runs[runId].artifacts.push(artifact);
});
} catch (err) {
console.error(" Error parsing artifact data: " + err);
process.exitCode = ExitCodes.ParseFailure;
}
}
}
class DataIO {
constructor() {
// Configurable parameters.
this.data_owner = "godotengine";
this.data_repo = "godot";
this.data_branch = "";
}
parseArgs() {
process.argv.forEach((arg) => {
if (arg.indexOf("owner:") === 0) {
this.data_owner = arg.substring(6);
}
if (arg.indexOf("repo:") === 0) {
this.data_repo = arg.substring(5);
}
if (arg.indexOf("branch:") === 0) {
this.data_branch = arg.substring(7);
}
});
if (this.data_owner === "" || this.data_repo === "" || this.data_branch === "") {
console.error(" Error reading command-line arguments: owner, repo, and branch cannot be empty.");
process.exitCode = ExitCodes.IOFailure;
return;
}
}
async loadData() {
try {
console.log("[*] Loading existing database from a file.");
// const dataPath = `./out/data/${this.data_owner}.${this.data_repo}.${this.data_branch}.json`;
// await fs.access(dataPath, fsConstants.R_OK);
// const existingData = await fs.readFile(dataPath);
} catch (err) {
console.error(" Error loading existing database file: " + err);
process.exitCode = ExitCodes.IOFailure;
return;
}
}
async saveData(output, fileName) {
try {
console.log("[*] Storing database to a file.");
await ensureDir("./out");
await ensureDir("./out/data");
await fs.writeFile(`./out/data/${fileName}`, JSON.stringify(output), {encoding: "utf-8"});
} catch (err) {
console.error(" Error saving database file: " + err);
process.exitCode = ExitCodes.IOFailure;
return;
}
}
}
function mapNodes(object) {
return object.edges.map((item) => item["node"])
}
async function ensureDir(dirPath) {
try {
await fs.access(dirPath, fsConstants.R_OK | fsConstants.W_OK);
} catch (err) {
await fs.mkdir(dirPath);
}
}
async function clearDir(rootPath) {
try {
const pathStat = await fs.stat(rootPath);
if (!pathStat.isDirectory()) {
return;
}
const removeDir = async (dirPath) => {
const dirFiles = await fs.readdir(dirPath);
for (let entryName of dirFiles) {
if (entryName === "." || entryName === "..") {
continue;
}
const entryPath = `${dirPath}/${entryName}`;
const entryStat = await fs.stat(entryPath);
if (entryStat.isDirectory()) {
await removeDir(entryPath);
await fs.rmdir(entryPath);
}
else if (entryStat.isFile()) {
await fs.unlink(entryPath);
}
}
};
await removeDir(rootPath);
} catch (err) {
console.error(` Error clearing a folder at ${rootPath}: ` + err);
process.exitCode = ExitCodes.IOFailure;
return;
}
}
async function main() {
// Internal utility methods.
const checkForExit = () => {
if (process.exitCode > 0) {
console.log(` Terminating with an exit code ${process.exitCode}.`);
process.exit();
}
};
console.log("[*] Building local workflow run database.");
const dataIO = new DataIO();
dataIO.parseArgs();
checkForExit();
// await dataIO.loadConfig();
// checkForExit();
console.log(`[*] Configured for the "${dataIO.data_owner}/${dataIO.data_repo}" repository; branch ${dataIO.data_branch}.`);
const dataFetcher = new DataFetcher(dataIO.data_owner, dataIO.data_repo);
const dataProcessor = new DataProcessor();
console.log("[*] Checking the rate limits before.");
await dataFetcher.checkRates();
checkForExit();
console.log("[*] Fetching workflow runs data from GitHub.");
const runsRaw = await dataFetcher.fetchRuns(dataIO.data_branch);
checkForExit();
dataProcessor.processRuns(runsRaw);
checkForExit();
console.log("[*] Fetching artifact data from GitHub.");
for (let runId in dataProcessor.runs) {
const artifactsRaw = await dataFetcher.fetchArtifacts(runId);
checkForExit();
dataProcessor.processArtifacts(runId, artifactsRaw);
checkForExit();
// Wait for a bit before proceeding to avoid hitting the secondary rate limit in GitHub API.
// See https://docs.github.com/en/rest/guides/best-practices-for-integrators#dealing-with-secondary-rate-limits.
await dataFetcher.delay(API_DELAY_MSEC);
}
console.log("[*] Checking the rate limits after.")
await dataFetcher.checkRates();
checkForExit();
console.log("[*] Finalizing database.")
const output = {
"generated_at": Date.now(),
"commits": dataProcessor.commits,
"checks": dataProcessor.checks,
"runs": dataProcessor.runs,
"artifacts": dataProcessor.artifacts,
};
await dataIO.saveData(output, `${dataIO.data_owner}.${dataIO.data_repo}.${dataIO.data_branch}.json`);
checkForExit();
console.log("[*] Database built.");
}
main();

1161
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "godot-commit-artifacts",
"version": "1.0.0",
"description": "",
"scripts": {
"build": "rollup -c",
"compose-db": "node ./compose-db.js"
},
"author": "Yuri Sizov <yuris@humnom.net>",
"private": true,
"dependencies": {
"@babel/core": "^7.6.4",
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-proposal-decorators": "^7.6.0",
"dompurify": "^2.0.7",
"lit-element": "^2.2.1",
"marked": "^0.7.0",
"node-fetch": "^2.6.1",
"posthtml": "^0.12.0",
"rollup": "^1.24.0",
"rollup-plugin-babel": "^4.3.3",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-copy": "^3.4.0",
"rollup-plugin-includepaths": "^0.2.3",
"rollup-plugin-node-resolve": "^5.2.0"
}
}

116
rollup.config.js Normal file
View File

@@ -0,0 +1,116 @@
import { promises as fs } from 'fs';
import path from 'path';
import nodeResolve from 'rollup-plugin-node-resolve';
import includePaths from 'rollup-plugin-includepaths'
import commonjs from 'rollup-plugin-commonjs';
import copy from 'rollup-plugin-copy';
import posthtmlTemplate from './build/rollup-posthtml-template';
import babel from 'rollup-plugin-babel';
const INPUT_ROOT = 'src';
const INPUT_PATHS_ROOT = path.join(INPUT_ROOT, 'paths');
const INPUT_SHARED_ROOT = path.join(INPUT_ROOT, 'shared');
const INPUT_STATIC_ROOT = path.join(INPUT_ROOT, 'static');
const ENTRY_FILE_NAME = 'entry.js';
const TEMPLATE_FILE_NAME = 'template.html';
const GLOBAL_FILE_NAME = 'global.js';
const OUTPUT_ROOT = 'out';
const OUTPUT_STYLES = path.join(OUTPUT_ROOT, 'styles');
const OUTPUT_STYLES_SHARED = path.join(OUTPUT_STYLES, 'shared');
const OUTPUT_SCRIPTS = path.join(OUTPUT_ROOT, 'scripts');
const OUTPUT_SCRIPTS_SHARED = path.join(OUTPUT_SCRIPTS, 'shared');
const generateConfig = async () => {
let configs = [];
getGlobalConfig(configs);
await getPathsConfigs(configs);
return configs;
};
const getGlobalConfig = (configs) => {
const globalScriptPath = path.join(INPUT_SHARED_ROOT, 'scripts', GLOBAL_FILE_NAME);
const outputPath = path.join(OUTPUT_SCRIPTS_SHARED, GLOBAL_FILE_NAME);
const sharedStylesGlob = path.join(INPUT_SHARED_ROOT, 'styles/**/*.css').replace(/\\/g, '/'); // Windows path not supported by copy plugin
const staticGlob = path.join(INPUT_STATIC_ROOT, '/**/*.*').replace(/\\/g, '/'); // Windows path not supported by copy plugin
configs.push({
input: globalScriptPath,
output: {
name: 'global',
file: outputPath,
format: 'iife'
},
plugins: [
nodeResolve(),
copy({
targets: [
{ src: sharedStylesGlob, dest: OUTPUT_STYLES_SHARED },
{ src: staticGlob, dest: OUTPUT_ROOT },
],
verbose: true
})
]
})
};
const getPathsConfigs = async (configs) => {
try {
// Collect paths to process
const paths = await fs.readdir(INPUT_PATHS_ROOT);
for (const itemPath of paths) {
const itemRoot = path.join(INPUT_PATHS_ROOT, itemPath);
const itemFiles = await fs.readdir(itemRoot);
if (itemFiles.indexOf(ENTRY_FILE_NAME) < 0) {
throw Error(`Missing entry script for "${itemPath}" path`);
}
if (itemFiles.indexOf(TEMPLATE_FILE_NAME) < 0) {
throw Error(`Missing HTML template for "${itemPath}" path`);
}
const entryPath = path.join(itemRoot, ENTRY_FILE_NAME);
const templatePath = path.join(itemRoot, TEMPLATE_FILE_NAME).replace(/\\/g, '/'); // Windows path not supported by copy plugin
const bundlePath = path.join(OUTPUT_ROOT, 'scripts', `${itemPath}.js`);
const htmlPath = path.join(OUTPUT_ROOT, `${itemPath}.html`);
configs.push({
input: entryPath,
output: {
name: itemPath,
file: bundlePath,
format: 'iife'
},
plugins: [
babel({
exclude: 'node_modules/**',
plugins: [
[ '@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true } ],
'@babel/plugin-proposal-class-properties'
]
}),
includePaths({
paths: [ './' ]
}),
nodeResolve(),
commonjs({
sourceMap: false
}),
posthtmlTemplate({
src: templatePath,
dest: htmlPath
})
]
})
}
} catch (exc) {
console.error(exc);
}
};
export default generateConfig();

View File

@@ -0,0 +1,85 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
@customElement('gr-index-description')
export default class IndexDescription extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
}
@media (prefers-color-scheme: dark) {
:host {
}
}
/** Component styling **/
:host {
line-height: 22px;
}
:host .header-description {
display: flex;
align-items: flex-end;
color: var(--dimmed-font-color);
}
:host .header-description-column {
flex: 2;
}
:host .header-description-column.header-extra-links {
flex: 1;
text-align: right;
}
:host .header-description a {
color: var(--link-font-color);
text-decoration: none;
}
:host .header-description a:hover {
color: var(--link-font-color-hover);
}
:host hr {
border: none;
border-top: 1px solid var(--g-background-extra-color);
width: 30%;
}
@media only screen and (max-width: 900px) {
:host .header-description {
padding: 0 8px;
flex-direction: column;
}
:host .header-description-column {
width: 100%;
}
:host .header-description-column.header-extra-links {
text-align: center;
padding-top: 12px;
}
}
`;
}
@property({ type: Date }) generated_at = null;
render() {
return html`
<div class="header-description">
<div class="header-description-column">
This page provides links to the latest CI build artifacts for
active development branches.
<br>
<strong>
These builds may not be suitable for production use.
<br>
Please use them for testing purposes only.
</strong>
</div>
<div class="header-description-column header-extra-links">
</div>
</div>
`;
}
}

View File

@@ -0,0 +1,116 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
@customElement('gr-index-entry')
export default class IndexHeader extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
--header-meta-color: #98a5b8;
}
@media (prefers-color-scheme: dark) {
:host {
--header-meta-color: #515c6c;
}
}
/** Component styling **/
:host {
}
:host .header {
display: flex;
justify-content: space-between;
align-items: center;
}
:host .header-metadata {
color: var(--header-meta-color);
text-align: right;
}
:host .header-metadata a {
color: var(--link-font-color);
text-decoration: none;
}
:host .header-metadata a:hover {
color: var(--link-font-color-hover);
}
@media only screen and (max-width: 900px) {
:host .header {
flex-wrap: wrap;
text-align: center;
}
:host .header-title,
:host .header-metadata {
width: 100%;
}
:host .header-metadata {
padding-bottom: 12px;
text-align: center;
}
}
`;
}
@property({ type: Date }) generated_at = null;
constructor() {
super();
// Auto-refresh about once a minute so that the relative time of generation is always actual.
this._refreshTimeout = setTimeout(this._refresh.bind(this), 60 * 1000);
}
_refresh() {
this.requestUpdate();
// Continue updating.
this._refreshTimeout = setTimeout(this._refresh.bind(this), 60 * 1000);
}
render() {
let generatedAt = "";
let generatedRel = "";
if (this.generated_at) {
generatedAt = greports.format.formatTimestamp(this.generated_at);
let timeValue = (Date.now() - this.generated_at) / (1000 * 60);
let timeUnit = "minute";
if (timeValue < 1) {
generatedRel = "just now";
} else {
if (timeValue > 60) {
timeValue = timeValue / 60;
timeUnit = "hour";
}
generatedRel = greports.format.formatTimespan(-Math.round(timeValue), timeUnit);
}
}
return html`
<div class="header">
<h1 class="header-title">
Godot Commit Artifacts
</h1>
<div class="header-metadata">
${(this.generated_at ? html`
<span title="${generatedAt}">
data generated ${generatedRel}
</span>
` : '')}
<br/>
<a
href="https://github.com/godotengine/godot-commit-artifacts"
target="_blank"
>
contribute on GitHub
</a>
</div>
</div>
`;
}
}

View File

@@ -0,0 +1,119 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
@customElement('gr-branch-item')
export default class BranchItem extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
--tab-hover-background-color: rgba(0, 0, 0, 0.14);
--tab-active-background-color: #d6e6ff;
--tab-active-border-color: #397adf;
}
@media (prefers-color-scheme: dark) {
:host {
--tab-hover-background-color: rgba(255, 255, 255, 0.14);
--tab-active-background-color: #283446;
--tab-active-border-color: #5394f9;
}
}
/** Component styling **/
:host {
max-width: 200px;
}
:host .branch-item {
border-left: 5px solid transparent;
color: var(--g-font-color);
cursor: pointer;
display: flex;
flex-direction: row;
gap: 6px;
padding: 6px 16px;
align-items: center;
}
:host .branch-item:hover {
background-color: var(--tab-hover-background-color);
}
:host .branch-item--active {
background-color: var(--tab-active-background-color);
border-left: 5px solid var(--tab-active-border-color);
}
:host .branch-title {
flex-grow: 1;
font-size: 15px;
white-space: nowrap;
overflow: hidden;
}
@keyframes loader-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
:host .branch-loader {
background-image: url('loader.svg');
background-size: 20px 20px;
background-position: 50% 50%;
background-repeat: no-repeat;
border-radius: 2px;
display: inline-block;
width: 20px;
height: 20px;
min-width: 20px;
animation-name: loader-rotate;
animation-duration: 1.25s;
animation-timing-function: steps(8);
animation-iteration-count: infinite;
}
@media (prefers-color-scheme: light) {
:host .branch-loader {
filter: invert(1);
}
}
@media only screen and (max-width: 900px) {
:host .branch-item {
padding: 10px 20px;
}
:host .branch-title {
font-size: 18px;
}
}
`;
}
@property({ type: String, reflect: true }) name = "";
@property({ type: Boolean, reflect: true }) active = false;
@property({ type: Boolean, reflect: true }) loading = false;
render(){
const classList = [ "branch-item" ];
if (this.active) {
classList.push("branch-item--active");
}
return html`
<div
class="${classList.join(" ")}"
title="${this.name}"
>
<span class="branch-title">
${this.name}
</span>
${(this.loading ? html`
<div class="branch-loader"></div>
` : null)}
</div>
`;
}
}

View File

@@ -0,0 +1,73 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
import BranchItem from "./BranchItem";
@customElement('gr-branch-list')
export default class BranchList extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
--branches-background-color: #fcfcfa;
--branches-border-color: #515c6c;
}
@media (prefers-color-scheme: dark) {
:host {
--branches-background-color: #0d1117;
--branches-border-color: #515c6c;
}
}
/** Component styling **/
:host {
position: relative;
}
:host .branch-list {
background-color: var(--branches-background-color);
border-right: 2px solid var(--branches-border-color);
width: 200px;
min-height: 216px;
}
@media only screen and (max-width: 900px) {
:host {
width: 100%
}
:host .branch-list {
width: 100% !important;
}
}
`;
}
@property({ type: Array }) branches = [];
@property({ type: Array }) loadingBranchess = [];
@property({ type: String }) selectedBranch = "";
_onItemClicked(branchName) {
this.dispatchEvent(greports.util.createEvent("branchclick", {
"branch": branchName,
}));
}
render() {
return html`
<div class="branch-list">
${this.branches.map((item) => {
return html`
<div class="branch-list-main">
<gr-branch-item
.name="${item}"
?active="${this.selectedBranch === item}"
?loading="${this.loadingBranches.includes(item)}"
@click="${this._onItemClicked.bind(this, item)}"
></gr-branch-item>
</div>
`;
})}
</div>
`;
}
}

View File

@@ -0,0 +1,139 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
@customElement('gr-commit-item')
export default class CommitItem extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
--item-border-color: #fcfcfa;
}
@media (prefers-color-scheme: dark) {
:host {
--item-border-color: #0d1117;
}
}
/** Component styling **/
:host {
border-bottom: 3px solid var(--item-border-color);
display: block;
padding: 14px 12px 20px 12px;
}
:host a {
color: var(--link-font-color);
text-decoration: none;
}
:host a:hover {
color: var(--link-font-color-hover);
}
:host .item-title {
display: inline-flex;
justify-content: space-between;
font-size: 20px;
margin-top: 6px;
margin-bottom: 12px;
width: 100%;
}
:host .item-subtitle {
color: var(--dimmed-font-color);
font-size: 16px;
line-height: 20px;
word-break: break-word;
}
:host .item-workflows {
margin-top: 12px;
}
:host .workflow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
border-bottom: 2px solid var(--g-background-extra-color);
padding: 12px 10px;
}
:host .workflow-artifacts {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--dimmed-font-color);
font-size: 14px;
}
:host .workflow-artifacts a {
font-size: 15px;
font-weight: 600;
}
@media only screen and (max-width: 900px) {
:host {
padding: 14px 0 20px 0;
}
:host .workflow {
grid-template-columns: 1fr;
}
}
@media only screen and (max-width: 640px) {
:host .item-container {
padding: 0 10px;
}
}
`;
}
@property({ type: String, reflect: true }) hash = '';
@property({ type: String }) title = '';
@property({ type: Array }) workflows = [];
@property({ type: String }) repository = '';
render(){
return html`
<div class="item-container">
<div class="item-title">
<span>${greports.format.formatTimestamp(this.committed_date)}</span>
<a
href="https://github.com/${this.repository}/commit/${this.hash}"
target="_blank"
title="Open commit #${this.hash} on GitHub"
>
#${this.hash.substring(0, 9)}
</a>
</div>
<div class="item-subtitle">${this.title}</div>
<div class="item-workflows">
${this.workflows.map((item) => {
return html`
<div class="workflow">
<div class="workflow-name">${item.name}</div>
<div class="workflow-artifacts">
${item.artifacts.map((artifact) => {
return html`
<span>
<a
href="https://github.com/godotengine/godot/suites/${item.check_id}/artifacts/${artifact.id}"
target="_blank"
>
${artifact.name}
</a>
<span>(${greports.format.humanizeBytes(artifact.size)})</span>
</span>
`;
})}
</div>
</div>
`;
})}
</div>
</div>
`;
}
}

View File

@@ -0,0 +1,122 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
import CommitItem from "./CommitItem";
@customElement('gr-commit-list')
export default class CommitList extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
--item-border-color: #fcfcfa;
--commits-background-color: #e5edf8;
}
@media (prefers-color-scheme: dark) {
:host {
--item-border-color: #0d1117;
--commits-background-color: #191d23;
}
}
/** Component styling **/
:host {
flex-grow: 1;
}
:host .branch-commits {
display: flex;
flex-direction: column;
gap: 24px;
background-color: var(--commits-background-color);
border-radius: 0 4px 4px 0;
padding: 8px 12px;
max-width: 760px;
}
@media only screen and (max-width: 900px) {
:host .branch-commits {
padding: 8px;
max-width: 95%;
margin: 0px auto;
}
}
:host .branch-commits-empty {
color: var(--g-font-color);
display: inline-block;
font-size: 20px;
line-height: 24px;
margin-top: 6px;
margin-bottom: 12px;
padding: 14px 12px;
word-break: break-word;
}
`;
}
@property({ type: Array }) commits = [];
@property({ type: Object }) checks = {};
@property({ type: Object }) runs = {};
@property({ type: Object }) artifacts = {};
@property({ type: String }) selectedRepository = "";
@property({ type: String }) selectedBranch = "";
@property({ type: Boolean, reflect: true }) loading = false;
render(){
if (this.selectedBranch === "") {
return html``;
}
if (this.loading) {
return html`
<span class="branch-commits-empty">Loading artifacts...</span>
`
}
return html`
<div class="branch-commits">
${this.commits.map((item) => {
let workflows = [];
for (let checkId in this.checks) {
const check = this.checks[checkId];
if (item.checks.indexOf(check.check_id) < 0) {
continue;
}
if (check.workflow == null || typeof this.runs[check.workflow] === "undefined") {
continue;
}
const run = this.runs[check.workflow];
if (run.artifacts.length === 0) {
continue;
}
workflows.push({
"name": run.name,
"name_sanitized": run.name.replace(/([^a-zA-Z0-9_\- ]+)/g, "").trim().toLowerCase(),
"check_id": check.check_id,
"artifacts": run.artifacts,
});
}
workflows.sort((a,b) => {
if (a.name_sanitized > b.name_sanitized) return 1;
if (a.name_sanitized < b.name_sanitized) return -1;
return 0;
});
return html`
<gr-commit-item
.hash="${item.hash}"
.title="${item.title}"
.committed_date="${item.committed_date}"
.workflows="${workflows}"
.repository="${this.selectedRepository}"
/>
`;
})}
</div>
`;
}
}

167
src/paths/index/entry.js Normal file
View File

@@ -0,0 +1,167 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
import PageContent from 'src/shared/components/PageContent';
import SharedNavigation from 'src/shared/components/SharedNavigation';
import IndexHeader from "./components/IndexHeader";
import IndexDescription from "./components/IndexDescription";
import BranchList from "./components/branches/BranchList";
import CommitList from "./components/commits/CommitList";
@customElement('entry-component')
export default class EntryComponent extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
}
@media (prefers-color-scheme: dark) {
:host {
}
}
/** Component styling **/
:host {
}
:host .branches {
display: flex;
padding: 24px 0;
}
@media only screen and (max-width: 900px) {
:host .branches {
flex-wrap: wrap;
}
}
`;
}
constructor() {
super();
this._entryRequested = false;
this._isLoading = true;
this._loadingBranches = [];
this._branches = [ "master" ];
this._branchData = {};
this._selectedRepository = "godotengine/godot";
this._selectedBranch = "";
this._restoreUserPreferences();
this._requestData();
}
performUpdate() {
this._requestData();
super.performUpdate();
}
_restoreUserPreferences() {
const userPreferences = greports.util.getLocalPreferences();
// ...
}
_saveUserPreferences() {
const currentPreferences = {
// ...
};
greports.util.setLocalPreferences(currentPreferences);
}
async _requestData() {
if (this._entryRequested) {
return;
}
this._entryRequested = true;
this._isLoading = true;
this._isLoading = false;
this.requestUpdate();
this._branches.forEach((branch) => {
this._requestBranchData(branch);
});
}
async _requestBranchData(branch) {
// Start loading, show the indicator.
this._loadingBranches.push(branch);
const branchData = await greports.api.getBranchData(this._selectedRepository, branch);
if (branchData) {
this._branchData[branch] = branchData;
}
// Finish loading, hide the indicator.
const index = this._loadingBranches.indexOf(branch);
this._loadingBranches.splice(index, 1);
this.requestUpdate();
}
_onBranchClicked(event) {
this._selectedBranch = event.detail.branch;
this.requestUpdate();
window.scrollTo(0, 0);
}
render() {
// Dereferencing to ensure it triggers an update.
const [...branches] = this._branches;
const [...loadingBranches] = this._loadingBranches;
let commits = [];
let checks = {};
let runs = {};
let artifacts = {};
if (this._selectedBranch !== "" && typeof this._branchData[this._selectedBranch] !== "undefined") {
const branchData = this._branchData[this._selectedBranch];
commits = branchData.commits;
checks = branchData.checks;
runs = branchData.runs;
artifacts = branchData.artifacts;
}
return html`
<page-content>
<shared-nav></shared-nav>
<gr-index-entry></gr-index-entry>
<gr-index-description></gr-index-description>
${(this._isLoading ? html`
<h3>Loading...</h3>
` : html`
<div class="branches">
<gr-branch-list
.branches="${branches}"
.loadingBranches="${loadingBranches}"
.selectedBranch="${this._selectedBranch}"
@branchclick="${this._onBranchClicked}"
></gr-branch-list>
${(this._selectedBranch !== "" ? html`
<gr-commit-list
.commits="${commits}"
.checks="${checks}"
.runs="${runs}"
.artifacts="${artifacts}"
.selectedRepository="${this._selectedRepository}"
.selectedBranch="${this._selectedBranch}"
?loading="${loadingBranches.indexOf(this._selectedBranch) >= 0}"
></gr-commit-list>
` : null)}
</div>
`)}
</page-content>
`;
}
}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<include src="shared/partials/head_content.html"></include>
<title>Godot Interactive Changelog</title>
<meta name="description" content="Godot Engine interactive changelog for each official release of the engine">
<meta name="keywords" content="godot, godot engine, gamedev, changelog, project management">
<script src="scripts/index.js"></script>
</head>
<body>
<entry-component></entry-component>
<include src="shared/partials/body_content.html"></include>
</body>
</html>

View File

@@ -0,0 +1,28 @@
import { LitElement, html, css, customElement } from 'lit-element';
@customElement('page-content')
export default class PageContent extends LitElement {
static get styles() {
return css`
/** Component styling **/
:host {
display: block;
margin: 0 auto;
padding: 0 12px;
max-width: 1024px;
}
@media only screen and (max-width: 900px) {
:host {
padding: 0;
}
}
`;
}
render(){
return html`
<slot></slot>
`;
}
}

View File

@@ -0,0 +1,119 @@
import { LitElement, html, css, customElement } from 'lit-element';
@customElement('shared-nav')
export default class SharedNavigation extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
}
@media (prefers-color-scheme: dark) {
:host {
}
}
/** Component styling **/
:host {
}
:host .nav-container a {
color: var(--link-font-color);
text-decoration: none;
}
:host .nav-container a:hover {
color: var(--link-font-color-hover);
}
:host .nav-container {
display: flex;
gap: 8px;
margin-top: 8px;
background: var(--g-background-color);
}
:host .nav-item {
font-size: 16px;
font-weight: 600;
padding: 10px 16px;
}
:host .nav-item:hover {
background-color: var(--g-background-extra2-color);
}
:host .nav-toggler {
display: none;
background-image: url('hamburger.svg');
background-repeat: no-repeat;
background-position: center;
cursor: pointer;
position: absolute;
top: 0;
left: 0;
width: 48px;
height: 48px;
}
:host .nav-toggler:hover {
background-color: var(--g-background-extra2-color);
}
@media only screen and (max-width: 640px) {
:host .nav-container {
display: none;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
right: 0;
padding-top: 40px;
padding-bottom: 12px;
}
:host .nav-container.nav-active {
display: flex;
}
:host .nav-toggler {
display: block;
}
}
`;
}
constructor() {
super();
this._mobileActive = false;
}
_onMobileToggled() {
this._mobileActive = !this._mobileActive;
this.requestUpdate();
}
render(){
const containerClassList = [ "nav-container" ];
if (this._mobileActive) {
containerClassList.push("nav-active");
}
return html`
<div class="${containerClassList.join(" ")}">
<a href="https://godotengine.github.io/doc-status/" target="_blank" class="nav-item">
ClassRef Status
</a>
<a href="https://godot-proposals-viewer.github.io/" target="_blank" class="nav-item">
Proposal Viewer
</a>
<a href="https://godotengine.github.io/godot-team-reports/" target="_blank" class="nav-item">
Team Reports
</a>
<a href="https://godotengine.github.io/godot-prs-by-file/" target="_blank" class="nav-item">
PRs by File
</a>
</div>
<div
class="nav-toggler"
@click="${this._onMobileToggled}"
></div>
`;
}
}

View File

View File

@@ -0,0 +1,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500i,700,900&display=swap&subset=latin-ext" rel="stylesheet">
<link rel="icon" type="image/png" href="favicon.png" />
<link href="styles/shared/normalize.css" rel="stylesheet">
<link href="styles/shared/global.css" rel="stylesheet">
<script src="scripts/shared/global.js"></script>

View File

@@ -0,0 +1,155 @@
const LOCAL_PREFERENCE_PREFIX = "_godot_cmtar"
const LOCAL_PREFERENCE_DEFAULTS = {
};
// API Interaction
const ReportsAPI = {
async get(path = '/') {
const res = await fetch(`${path}`);
if (res.status !== 200) {
return null;
}
return await res.json();
},
async getBranchData(repositoryId, branchName) {
const idBits = repositoryId.split("/");
return await this.get(`data/${idBits[0]}.${idBits[1]}.${branchName}.json`);
},
};
// Content helpers
const ReportsFormatter = {
formatDate(dateString) {
const options = {
year: 'numeric', month: 'long', day: 'numeric',
};
const dateFormatter = new Intl.DateTimeFormat('en-US', options);
const date = new Date(dateString);
return dateFormatter.format(date);
},
formatTimestamp(timeString) {
const options = {
year: 'numeric', month: 'long', day: 'numeric',
hour: 'numeric', hour12: false, minute: 'numeric',
timeZone: 'UTC', timeZoneName: 'short',
};
const dateFormatter = new Intl.DateTimeFormat('en-US', options);
const date = new Date(timeString);
return dateFormatter.format(date);
},
formatTimespan(timeValue, timeUnit) {
const options = {
style: 'long',
};
const timeFormatter = new Intl.RelativeTimeFormat('en-US', options);
return timeFormatter.format(timeValue, timeUnit);
},
getDaysSince(dateString) {
const date = new Date(dateString);
const msBetween = (new Date()) - date;
const days = Math.floor(msBetween / (1000 * 60 * 60 * 24));
return days;
},
formatDays(days) {
return days + " " + (days !== 1 ? "days" : "day");
},
humanizeBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
bytes = bytes / 1024;
if (bytes < 1024) {
return `${Math.round(bytes, 2)} KB`;
}
bytes = bytes / 1024;
if (bytes < 1024) {
return `${Math.round(bytes, 2)} MB`;
}
bytes = bytes / 1024;
if (bytes < 1024) {
return `${Math.round(bytes, 2)} GB`;
}
bytes = bytes / 1024;
return `${Math.round(bytes, 2)} TB`;
}
};
const ReportsUtils = {
createEvent(name, detail = {}) {
return new CustomEvent(name, {
detail: detail
});
},
getHistoryHash() {
let rawHash = window.location.hash;
if (rawHash !== "") {
return rawHash.substr(1);
}
return "";
},
setHistoryHash(hash) {
const url = new URL(window.location);
url.hash = hash;
window.history.pushState({}, "", url);
},
navigateHistoryHash(hash) {
this.setHistoryHash(hash);
window.location.reload();
},
getLocalPreferences() {
// Always fallback on defaults.
const localPreferences = { ...LOCAL_PREFERENCE_DEFAULTS };
for (let key in localPreferences) {
const storedValue = localStorage.getItem(`${LOCAL_PREFERENCE_PREFIX}_${key}`);
if (storedValue != null) {
localPreferences[key] = JSON.parse(storedValue);
}
}
return localPreferences;
},
setLocalPreferences(currentPreferences) {
for (let key in currentPreferences) {
// Only store known properties.
if (key in LOCAL_PREFERENCE_DEFAULTS) {
localStorage.setItem(`${LOCAL_PREFERENCE_PREFIX}_${key}`, JSON.stringify(currentPreferences[key]));
}
}
},
resetLocalPreferences() {
this.setLocalPreferences(LOCAL_PREFERENCE_DEFAULTS);
},
};
const ReportsSingleton = {
api: ReportsAPI,
format: ReportsFormatter,
util: ReportsUtils,
};
window.greports = ReportsSingleton;

View File

@@ -0,0 +1,55 @@
/** Colors and variables **/
:root {
--g-background-color: #fcfcfa;
--g-background-extra-color: #98a5b8;
--g-background-extra2-color: #cad3e1;
--g-font-color: #121314;
--g-font-size: 15px;
--g-font-weight: 400;
--g-line-height: 20px;
--link-font-color: #1d6dff;
--link-font-color-hover: #1051c9;
--link-font-color-inactive: #35496f;
--dimmed-font-color: #535c5f;
--light-font-color: #6b7893;
}
@media (prefers-color-scheme: dark) {
:root {
--g-background-color: #0d1117;
--g-background-extra-color: #515c6c;
--g-background-extra2-color: #22252b;
--g-font-color: rgba(228, 228, 232, 0.9);
--link-font-color: #367df7;
--link-font-color-hover: #6391ec;
--link-font-color-inactive: #abbdcc;
--dimmed-font-color: #929da0;
--light-font-color: #8491ab;
}
}
/** General styling **/
html {}
body {
background: var(--g-background-color);
color: var(--g-font-color);
font-family: 'Roboto', sans-serif;
font-size: var(--g-font-size);
font-weight: var(--g-font-weight);
line-height: var(--g-line-height);
min-width: 380px;
}
a {
color: var(--link-font-color);
font-weight: 700;
text-decoration: none;
}
a:hover {
color: var(--link-font-color-hover);
}

349
src/shared/styles/normalize.css vendored Normal file
View File

@@ -0,0 +1,349 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

BIN
src/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="#e0e0e0"><g fill-opacity=".2"><path d="m10.061 11.466 2.152 2.152c-.924.694-2.023 1.166-3.22 1.336v-3.045c.381-.096.74-.247 1.068-.443z"/><path d="m5.939 11.466c.328.196.687.347 1.068.443v3.045c-1.197-.17-2.297-.642-3.22-1.336z"/><path d="m14.954 8.993c-.17 1.197-.642 2.297-1.336 3.22l-2.152-2.152c.196-.328.347-.687.443-1.068z"/><path d="m1.046 8.993h3.045c.096.381.247.74.443 1.068l-2.152 2.152c-.694-.923-1.166-2.023-1.336-3.22z"/><path d="m2.382 3.787 2.152 2.152c-.196.329-.347.687-.443 1.068h-3.045c.17-1.197.642-2.297 1.336-3.22z"/><path d="m13.618 3.787c.694.923 1.166 2.023 1.336 3.22h-3.045c-.096-.381-.247-.74-.443-1.068z"/><path d="m7.007 1.046v3.045c-.381.096-.74.247-1.068.443l-2.152-2.152c.923-.694 2.023-1.166 3.22-1.336z"/></g><path d="m8.993 1.046c1.197.17 2.297.642 3.22 1.336l-2.152 2.152c-.328-.196-.687-.347-1.068-.443z"/></g></svg>

After

Width:  |  Height:  |  Size: 1011 B