Update project setup (#2)

* Update custom SVG files

* Update linting and formatting

* Remove build of external dependency

* Format files

* Update biome js

* Update dependencies

* Update project

* Update lint staged

* Update meta info

* Remove SVGO config

* Transform JS to TS

* Update link to repo in footer

* Update release workflow

* Update dependencies

* Update package json

* Update workflow

* Transform require to import

* Replace node-fetch with native fetch

* Replace parcel with ESBuild

* Remove flakyness of build language script

* Update release workflow for Edge

* Update readme
This commit is contained in:
Philipp Kief 2024-06-30 20:17:52 +02:00
parent e153f99d10
commit 11b495ab4d
No known key found for this signature in database
GPG Key ID: CC872C197CBB41C8
61 changed files with 10158 additions and 25383 deletions

View File

@ -1,2 +0,0 @@
dist
node_modules

View File

@ -1,41 +0,0 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"globals": {
"chrome": "readonly"
},
"extends": ["airbnb-base", "plugin:jsdoc/recommended", "prettier"],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
"no-use-before-define": ["error", { "functions": false }],
"jsdoc/require-jsdoc": "off",
"import/prefer-default-export": "off",
"consistent-return": "off",
"no-restricted-syntax": "off"
},
"overrides": [
{
"files": ["scripts/*.js", "svgo.config.js"],
"parserOptions": {
"sourceType": "script"
},
"rules": {
"import/no-extraneous-dependencies": "off",
"import/no-dynamic-require": "off",
"no-console": "off"
}
},
{
"files": ["src/providers/*.js", "src/lib/*.js"],
"rules": {
"no-param-reassign": "off"
}
}
]
}

View File

@ -2,25 +2,39 @@ name: Release new version
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
type: versionChange:
description: major|minor|patch - defaults to patch type: choice
description: Select the version change
required: true required: true
default: patch default: 'patch'
options:
- major
- minor
- patch
onlyUpload: onlyUpload:
description: chrome|firefox|both|not|none - Only upload to store(s) without bumping version or releasing description: chrome|firefox|both|not|none - Only upload to store(s) without bumping version or releasing
required: true required: true
default: not default: not
permissions:
contents: read
jobs: jobs:
get-version: get-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: '16.13.0' node-version: "20"
- name: install dependencies - name: install dependencies
run: npm ci run: npm ci
@ -35,20 +49,19 @@ jobs:
if: ${{ github.event.inputs.onlyUpload == 'not' || github.event.inputs.onlyUpload == 'none' }} if: ${{ github.event.inputs.onlyUpload == 'not' || github.event.inputs.onlyUpload == 'none' }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run release ${{ github.event.inputs.type }} run: npm run release ${{ github.event.inputs.versionChange }}
- name: Get package version - name: Get package version
if: ${{ github.event.inputs.onlyUpload == 'not' || github.event.inputs.onlyUpload == 'none' }} if: ${{ github.event.inputs.onlyUpload == 'not' || github.event.inputs.onlyUpload == 'none' }}
id: package-version run: echo "package_version=v$(jq -r .version package.json)" >> $GITHUB_ENV
run: echo ::set-output name=package_version::v$(jq -r .version package.json)
- name: Commit updated files - name: Commit updated files
if: ${{ github.event.inputs.onlyUpload == 'not' || github.event.inputs.onlyUpload == 'none' }} if: ${{ github.event.inputs.onlyUpload == 'not' || github.event.inputs.onlyUpload == 'none' }}
uses: EndBug/add-and-commit@v7 uses: EndBug/add-and-commit@v7
with: with:
add: '.' add: "."
message: '[auto] release ${{steps.package-version.outputs.package_version}}' message: "[auto] release ${{env.package_version}}"
tag: '${{steps.package-version.outputs.package_version}}' tag: "${{env.package_version}}"
- name: Release - name: Release
if: ${{ github.event.inputs.onlyUpload == 'not' || github.event.inputs.onlyUpload == 'none' }} if: ${{ github.event.inputs.onlyUpload == 'not' || github.event.inputs.onlyUpload == 'none' }}
@ -56,15 +69,15 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: '${{steps.package-version.outputs.package_version}}' tag_name: "${{env.package_version}}"
files: '*.zip' files: "*.zip"
- name: Upload to chrome store - name: Upload to chrome store
if: ${{ github.event.inputs.onlyUpload != 'firefox' && github.event.inputs.onlyUpload != 'none' }} if: ${{ github.event.inputs.onlyUpload != 'firefox' && github.event.inputs.onlyUpload != 'none' }}
continue-on-error: true continue-on-error: true
uses: trmcnvn/chrome-addon@v2 uses: trmcnvn/chrome-addon@v2
with: with:
extension: bggfcpfjbdkhfhfmkjpbhnkhnpjjeomc extension: hopghfcljkdgmajlhdfpgpcemcfhbili
zip: github-material-icons-chrome-extension.zip zip: github-material-icons-chrome-extension.zip
client-id: ${{ secrets.CHROME_CLIENT_ID }} client-id: ${{ secrets.CHROME_CLIENT_ID }}
client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
@ -75,7 +88,7 @@ jobs:
continue-on-error: true continue-on-error: true
uses: wdzeng/edge-addon@v1 uses: wdzeng/edge-addon@v1
with: with:
product-id: d7692295-d84f-4bf5-9447-3cbb6ae29517 product-id: fmnacigfpppckhpaafbjdhljbjjclkkj
zip-path: github-material-icons-edge-extension.zip zip-path: github-material-icons-edge-extension.zip
client-id: ${{ secrets.EDGE_CLIENT_ID }} client-id: ${{ secrets.EDGE_CLIENT_ID }}
client-secret: ${{ secrets.EDGE_CLIENT_SECRET }} client-secret: ${{ secrets.EDGE_CLIENT_SECRET }}

View File

@ -1,58 +1,68 @@
name: Auto Update when upstream releases name: Auto Update when upstream releases
on: on:
schedule: schedule:
- cron: '0 3 * * *' - cron: "0 3 * * *"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
get-version: get-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: '16.13.0' node-version: "20"
- name: Fetch release version - name: Fetch release version
id: upstream id: upstream
run: | run: |
echo ::set-output name=release_tag::$(curl "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/package.json" \ release_tag=$(npm view material-icon-theme version)
| jq -r .version) current_tag=$(npm list material-icon-theme --depth=0 | grep 'material-icon-theme@' | cut -d '@' -f 2)
echo ::set-output name=current_tag::$(<upstream.version) echo "release_tag=$release_tag" >> $GITHUB_ENV
echo "current_tag=$current_tag" >> $GITHUB_ENV
- name: Attempt update - name: Attempt update
if: steps.upstream.outputs.release_tag != steps.upstream.outputs.current_tag if: env.release_tag != env.current_tag
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm ci && npm run update run: npm ci && npm run update
- name: Get package version - name: Get package version
id: package-version if: env.release_tag != env.current_tag
if: steps.upstream.outputs.release_tag != steps.upstream.outputs.current_tag run: |
run: echo ::set-output name=package_version::v$(jq -r .version package.json) echo "package_version=v$(jq -r .version package.json)" >> $GITHUB_ENV
- name: Commit updated files - name: Commit updated files
if: steps.upstream.outputs.release_tag != steps.upstream.outputs.current_tag if: env.release_tag != env.current_tag
uses: EndBug/add-and-commit@v7 uses: EndBug/add-and-commit@v9
with: with:
add: '.' add: "."
message: '[auto] update to upstream ${{steps.upstream.outputs.release_tag}}' message: "[auto] update to upstream ${{env.release_tag}}"
tag: '${{steps.package-version.outputs.package_version}}' tag: "${{env.package_version}}"
- name: Release - name: Release
if: steps.upstream.outputs.release_tag != steps.upstream.outputs.current_tag if: env.release_tag != env.current_tag
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: '${{steps.package-version.outputs.package_version}}' tag_name: "${{env.package_version}}"
body: Built with icons from VSCode Material Icon Theme ${{steps.upstream.outputs.release_tag}} body: Built with icons from VSCode Material Icon Theme ${{env.release_tag}}
files: '*.zip' files: "*.zip"
- name: Upload to chrome store - name: Upload to chrome store
if: steps.upstream.outputs.release_tag != steps.upstream.outputs.current_tag if: env.release_tag != env.current_tag
continue-on-error: true continue-on-error: true
uses: trmcnvn/chrome-addon@v2 uses: trmcnvn/chrome-addon@v2
with: with:
@ -63,7 +73,7 @@ jobs:
refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }} refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
- name: Upload to edge store - name: Upload to edge store
if: steps.upstream.outputs.release_tag != steps.upstream.outputs.current_tag if: env.release_tag != env.current_tag
continue-on-error: true continue-on-error: true
uses: wdzeng/edge-addon@v1 uses: wdzeng/edge-addon@v1
with: with:
@ -74,6 +84,6 @@ jobs:
access-token-url: ${{ secrets.EDGE_ACCESS_TOKEN_URL }} access-token-url: ${{ secrets.EDGE_ACCESS_TOKEN_URL }}
- name: Upload to firefox store - name: Upload to firefox store
if: steps.upstream.outputs.release_tag != steps.upstream.outputs.current_tag if: env.release_tag != env.current_tag
continue-on-error: true continue-on-error: true
run: npx web-ext sign -s ./dist/firefox/ --channel=listed --api-key=${{ secrets.FIREFOX_API_JWT_ISSUER }} --api-secret=${{ secrets.FIREFOX_API_JWT_SECRET }} run: npx web-ext sign -s ./dist/firefox/ --channel=listed --api-key=${{ secrets.FIREFOX_API_JWT_ISSUER }} --api-secret=${{ secrets.FIREFOX_API_JWT_SECRET }}

7
.gitignore vendored
View File

@ -1,11 +1,8 @@
dist/ dist/
temp/ out/
.vscode/
github-material-icons-chrome-extension.zip github-material-icons-chrome-extension.zip
github-material-icons-firefox-extension.zip github-material-icons-firefox-extension.zip
github-material-icons-edge-extension.zip github-material-icons-edge-extension.zip
src/icon-cache.js
src/icon-map.json
src/icon-list.json src/icon-list.json
src/language-map.json src/language-map.json
svg/ svg/
@ -15,8 +12,6 @@ data/
node_modules node_modules
*.log *.log
.idea .idea
.cache/
.vscode/
.DS_Store .DS_Store
.eslintcache .eslintcache
Thumbs.db Thumbs.db

1
.nvmrc
View File

@ -1 +0,0 @@
16.13.0

View File

@ -1,4 +0,0 @@
.cache
dist
src/*.json
!src/manifest.json

View File

@ -1,13 +0,0 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always",
"proseWrap": "preserve",
"endOfLine": "lf"
}

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome", "jock.svg", "editorconfig.editorconfig"]
}

View File

@ -1,4 +1,4 @@
<h1 align="center">Material Icons Browser Addon</h1> <h1 align="center">Material Icons for Web</h1>
<div align="center"> <div align="center">
@ -10,7 +10,7 @@
<a href="https://addons.mozilla.org/de/firefox/addon/material-icons-for-web"><img src="https://github.com/material-extensions/material-icons-browser-addon/raw/main/assets/firefox-addons.png"></a> <a href="https://addons.mozilla.org/de/firefox/addon/material-icons-for-web"><img src="https://github.com/material-extensions/material-icons-browser-addon/raw/main/assets/firefox-addons.png"></a>
</p> </p>
<b>Install directly from the <a href="https://chromewebstore.google.com/detail/material-icons-for-web/hopghfcljkdgmajlhdfpgpcemcfhbili">Chrome Web Store</a> | <a href="https://microsoftedge.microsoft.com/addons/detail/material-icons-for-github/khckkdgomkcjjnpgjmdmbceiddlmiolb">Microsoft Edge Addons Store</a> | <a href="https://addons.mozilla.org/de/firefox/addon/material-icons-for-web">Firefox Addons</a></b></div> <b>Install directly from the <a href="https://chromewebstore.google.com/detail/material-icons-for-web/hopghfcljkdgmajlhdfpgpcemcfhbili">Chrome Web Store</a> | <a href="https://microsoftedge.microsoft.com/addons/detail/fmnacigfpppckhpaafbjdhljbjjclkkj">Microsoft Edge Addons Store</a> | <a href="https://addons.mozilla.org/de/firefox/addon/material-icons-for-web">Firefox Addons</a></b></div>
--- ---
@ -56,8 +56,3 @@ Update language-map.json with latest language contributions.
npm run build-languages npm run build-languages
``` ```
---
_Special thanks to [@shivapoudel](https://github.com/shivapoudel) for creating and maintaining the Microsoft Edge version of the extension_
_Original extension developed with [Richard Lam](https://github.com/rlam108)_

49
biome.jsonc Normal file
View File

@ -0,0 +1,49 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.2/schema.json",
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80,
"attributePosition": "auto"
},
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"complexity": { "useArrowFunction": "off" },
"correctness": { "noUnsafeFinally": "error" },
"security": { "noGlobalEval": "error" },
"style": {
"noVar": "error",
"useBlockStatements": "off",
"useConst": "error",
"useNamingConvention": {
"level": "error",
"options": { "strictCase": false }
}
},
"suspicious": {
"noDoubleEquals": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "single",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto"
}
},
"overrides": []
}

32510
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,10 @@
"name": "material-icons-browser-addon", "name": "material-icons-browser-addon",
"version": "1.8.15", "version": "1.8.15",
"description": "Browser Addon that enhances file browsers of version controls with material icons.", "description": "Browser Addon that enhances file browsers of version controls with material icons.",
"main": "src/main.js", "main": "src/main.ts",
"author": { "author": {
"name": "Philipp Kief", "name": "Material Extensions",
"email": "philipp.kief@gmx.de", "url": "https://github.com/material-extensions"
"url": "https://pkief.com"
}, },
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/material-extensions/material-icons-browser-addon#readme", "homepage": "https://github.com/material-extensions/material-icons-browser-addon#readme",
@ -17,62 +16,49 @@
"bugs": { "bugs": {
"url": "https://github.com/material-extensions/material-icons-browser-addon/issues" "url": "https://github.com/material-extensions/material-icons-browser-addon/issues"
}, },
"engines": {
"node": "^16.0.0",
"npm": "^8.0.0"
},
"dependencies": { "dependencies": {
"material-icon-theme": "latest",
"selector-observer": "2.1.6", "selector-observer": "2.1.6",
"webextension-polyfill": "0.11.0" "webextension-polyfill": "0.12.0"
}, },
"devDependencies": { "devDependencies": {
"@octokit/core": "3.5.1", "@octokit/core": "3.5.1",
"compare-versions": "3.6.0", "@types/fs-extra": "11.0.4",
"eslint": "8.18.0", "@types/json-stable-stringify": "1.0.36",
"eslint-config-airbnb-base": "15.0.0", "@types/webextension-polyfill": "0.10.7",
"eslint-config-prettier": "8.5.0", "esbuild": "0.21.5",
"eslint-plugin-import": "2.26.0", "fs-extra": "11.2.0",
"eslint-plugin-jsdoc": "39.3.3", "husky": "9.0.11",
"fs-extra": "10.0.0", "json-stable-stringify": "1.1.1",
"husky": "8.0.1", "lint-staged": "15.2.7",
"json-stable-stringify": "1.0.1",
"lint-staged": "13.0.3",
"node-fetch": "2.6.7",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"parcel-bundler": "1.12.5", "rimraf": "5.0.7",
"prettier": "2.7.1", "sharp": "0.33.4",
"rimraf": "3.0.2", "ts-node": "10.9.2",
"sharp": "0.28.3", "typescript": "5.5.2",
"simple-git": "2.40.0", "web-ext": "8.2.0"
"svgo": "2.3.1",
"web-ext": "7.1.0"
}, },
"scripts": { "scripts": {
"prebuild": "rimraf *.zip ./dist", "prebuild": "rimraf *.zip ./dist",
"build": "run-s build-ext-dependencies build-languages build-src bundle", "build": "run-s build-languages build-src bundle",
"build-ext-dependencies": "node ./scripts/build-dependencies.js", "build-languages": "ts-node ./scripts/build-languages.ts",
"build-languages": "node ./scripts/build-languages.js", "build-src": "ts-node ./scripts/build-src.ts",
"build-src": "node ./scripts/build-src.js", "rebuild-logos": "ts-node ./scripts/build-icons.ts",
"rebuild-logos": "node ./scripts/build-icons.js",
"bundle": "run-p bundle-edge bundle-chrome bundle-firefox", "bundle": "run-p bundle-edge bundle-chrome bundle-firefox",
"bundle-edge": "zip -r -j github-material-icons-edge-extension.zip dist/chrome-edge", "bundle-edge": "zip -r -j github-material-icons-edge-extension.zip dist/chrome-edge",
"bundle-chrome": "zip -r -j github-material-icons-chrome-extension.zip dist/chrome-edge", "bundle-chrome": "zip -r -j github-material-icons-chrome-extension.zip dist/chrome-edge",
"bundle-firefox": "web-ext -s ./dist/firefox/ -n github-material-icons-firefox-extension.zip -a . build --overwrite-dest", "bundle-firefox": "web-ext -s ./dist/firefox/ -n github-material-icons-firefox-extension.zip -a . build --overwrite-dest",
"parcel": "parcel build ./src/main.js", "update-manifest-version": "ts-node ./scripts/update-manifest-version.ts",
"parcel-watch": "parcel watch ./src/main.js", "update-upstream-version": "ts-node ./scripts/update-upstream-version.ts",
"clean": "rimraf *.zip ./dist ./svg ./.cache ./src/icon-list.json ./src/icon-map.json",
"update-manifest-version": "node ./scripts/update-manifest-version.js",
"update-upstream-version": "node ./scripts/update-upstream-version",
"update-package-version": "npm version --no-git-tag-version", "update-package-version": "npm version --no-git-tag-version",
"update": "run-s update-upstream-version \"update-package-version patch\" update-manifest-version build", "update": "run-s update-upstream-version \"update-package-version patch\" update-manifest-version build",
"release": "run-s \"update-package-version {1}\" update-manifest-version build --", "release": "run-s \"update-package-version {1}\" update-manifest-version build --",
"lint": "eslint .", "lint": "npx @biomejs/biome check --write ./src",
"lint-fix": "eslint --fix .", "format": "npx @biomejs/biome format --write ./src",
"format": "prettier --write --ignore-unknown .", "prepare": "husky"
"prepare": "husky install"
}, },
"lint-staged": { "lint-staged": {
"*.js": "eslint --fix", "*.ts": "npm run lint",
"*": "prettier --write --ignore-unknown" "*": "npm run format"
} }
} }

View File

@ -1,67 +0,0 @@
/**
* External depedencies
*/
const path = require('path');
const fs = require('fs-extra');
const simpleGit = require('simple-git');
const { execSync } = require('child_process');
/**
* Internal depedencies
*/
const srcPath = path.resolve(__dirname, '..', 'src');
const vsExtPath = path.resolve(__dirname, '..', 'temp');
const destSVGPath = path.resolve(__dirname, '..', 'svg');
const commitLockPath = path.resolve(__dirname, '..', 'upstream.commit');
const vsExtExecOptions = {
cwd: vsExtPath,
stdio: 'inherit',
};
const distIconsExecOptions = {
cwd: path.resolve(destSVGPath),
stdio: 'inherit',
};
async function main() {
await fs.remove(vsExtPath);
await fs.remove(destSVGPath);
await fs.ensureDir(destSVGPath);
console.log('[1/7] Cloning PKief/vscode-material-icon-theme into temporary cache.');
const git = simpleGit();
await git.clone(`https://github.com/PKief/vscode-material-icon-theme.git`, vsExtPath, [
'--depth',
'100', // fetch only last 100 commits. Guesswork, could be too shallow if upstream doesnt release often
]);
const commit = fs.readFileSync(commitLockPath, { encoding: 'utf8' })?.trim();
console.log('Checking out to upstream commit:', commit);
const upstreamGit = simpleGit(vsExtPath);
await upstreamGit.checkout(commit, ['--force']);
console.log('[2/7] Terminate Git repository in temporary cache.');
await fs.remove(path.resolve(vsExtPath, '.git'));
console.log('[3/7] Install NPM dependencies for VSC extension.');
execSync(`npm install --ignore-scripts`, vsExtExecOptions);
console.log('[4/7] Terminate Git tracking in temporary cache.');
await fs.copy(path.resolve(vsExtPath, 'icons'), path.resolve(destSVGPath));
console.log('[5/7] Optimise extension icons using SVGO.');
execSync(`npx svgo -r .`, distIconsExecOptions);
console.log('[6/7] Run build tasks for VSC extension.');
execSync(`npm run build`, vsExtExecOptions);
console.log('[7/7] Copy file icon configuration to source code directory.');
await fs.copy(
path.resolve(vsExtPath, 'dist', 'material-icons.json'),
path.resolve(srcPath, 'icon-map.json')
);
await fs.remove(vsExtPath);
}
main();

View File

@ -1,31 +0,0 @@
/**
* External depedencies
*/
const path = require('path');
const sharp = require('sharp');
const fs = require('fs-extra');
/**
* Internal depedencies
*/
const svgPath = path.resolve(__dirname, '..', 'src', 'logo.svg');
const iconsPath = path.resolve(__dirname, '..', 'src', 'extensionIcons');
const targetSizes = [16, 32, 48, 128];
// Build extension icons.
fs.ensureDir(iconsPath).then(generateIcons);
/**
* Generate extension icons.
*
* @since 1.4.0
*/
function generateIcons() {
targetSizes.forEach((size) => {
sharp(svgPath)
.png()
.resize({ width: size, height: size })
.toFile(`${iconsPath}/icon-${size}.png`)
.catch(console.error);
});
}

29
scripts/build-icons.ts Normal file
View File

@ -0,0 +1,29 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import sharp from 'sharp';
const svgPath: string = path.resolve(__dirname, '..', 'src', 'logo.svg');
const iconsPath: string = path.resolve(
__dirname,
'..',
'src',
'extensionIcons'
);
const targetSizes: number[] = [16, 32, 48, 128];
// Build extension icons.
fs.ensureDir(iconsPath).then(generateIcons);
/**
* Generate extension icons.
*
* @since 1.4.0
*/
function generateIcons(): void {
targetSizes.forEach((size: number) => {
sharp(svgPath)
.png()
.resize({ width: size, height: size })
.toFile(`${iconsPath}/icon-${size}.png`);
});
}

View File

@ -1,180 +0,0 @@
const path = require('path');
const api = require('@octokit/core');
const fs = require('fs-extra');
const fetch = require('node-fetch');
const stringify = require('json-stable-stringify');
const iconMap = require('../src/icon-map.json');
const vsDataPath = path.resolve(__dirname, '..', 'data');
const srcPath = path.resolve(__dirname, '..', 'src');
let index = 0;
let total;
const items = [];
const contributions = [];
const languages = [];
const resultsPerPage = 100; // max 100
const octokit = new api.Octokit({
auth: process.env.GITHUB_TOKEN,
});
const query = {
page: 0,
per_page: resultsPerPage,
q: 'contributes languages filename:package.json repo:microsoft/vscode',
};
const GITHUB_RATELIMIT = 6000;
async function main() {
await fs.remove(vsDataPath);
await fs.ensureDir(vsDataPath);
await fs.remove(path.resolve(srcPath, 'language-map.json'));
console.log('[1/7] Querying Github API for official VSC language contributions.');
queryLanguageContributions();
}
main();
async function queryLanguageContributions() {
const res = await octokit.request('GET /search/code', query);
if (!res.data) throw new Error();
query.page = index;
index += 1;
if (!total) total = res.data.total_count;
items.push(...res.data.items);
if (resultsPerPage * index >= total) {
console.log('[2/7] Fetching Microsoft language contributions from Github.');
index = 0;
total = items.length;
items.forEach(fetchLanguageContribution);
} else {
setTimeout(queryLanguageContributions, GITHUB_RATELIMIT);
}
}
async function fetchLanguageContribution(item) {
const rawUrl = item.html_url.replace('/blob/', '/raw/');
const resPath = item.path.replace(/[^/]+$/, 'extension.json');
const extPath = path.join(vsDataPath, resPath);
let extManifest;
try {
extManifest = await fetch(rawUrl);
extManifest = await extManifest.text();
} catch (reason) {
throw new Error(reason);
}
try {
await fs.ensureDir(path.dirname(extPath));
await fs.writeFile(extPath, extManifest, 'utf-8');
} catch (reason) {
throw new Error(`${reason} (${extPath})`);
}
items[index] = [extPath, extManifest];
index += 1;
if (index === total) {
console.log('[3/7] Loading VSC language contributions into Node.');
index = 0;
items.forEach(loadLanguageContribution);
}
}
function loadLanguageContribution([extPath, extManifest]) {
let data;
try {
data = JSON.parse(extManifest.replace(/#\w+_\w+#/g, '0'));
} catch (error) {
throw new Error(`${error} (${extPath})`);
}
if (!data.contributes || !data.contributes.languages) {
total -= 1;
return;
}
contributions.push(...data.contributes.languages);
index += 1;
if (index === total) {
console.log('[4/7] Processing language contributions for VSC File Icon API compatibility.');
index = 0;
total = contributions.length;
contributions.forEach(processLanguageContribution);
}
}
function processLanguageContribution(contribution) {
const { id, filenamePatterns } = contribution;
let { extensions, filenames } = contribution;
extensions = extensions || [];
filenames = filenames || [];
if (filenamePatterns) {
filenamePatterns.forEach((ptn) => {
if (/^\*\.[^*/?]+$/.test(ptn)) {
extensions.push(ptn.substring(1));
}
if (/^[^*/?]+$/.test(ptn)) {
filenames.push(ptn);
}
});
}
extensions = extensions
.map((ext) => (ext.charAt(0) === '.' ? ext.substring(1) : ext))
.filter((ext) => !/\*|\/|\?/.test(ext));
filenames = filenames.filter((name) => !/\*|\/|\?/.test(name));
if (!filenames.length && !extensions.length) {
total -= 1;
return;
}
const language = languages.find((lang) => lang.id === id);
if (language) {
language.filenames.push(...filenames);
language.extensions.push(...extensions);
} else {
languages.push({ id, extensions, filenames });
}
index += 1;
if (index === total) {
console.log('[5/7] Mapping language contributions into file icon configuration.');
index = 0;
total = languages.length;
languages.forEach(mapLanguageContribution);
}
}
const languageMap = {};
languageMap.fileExtensions = {};
languageMap.fileNames = {};
function mapLanguageContribution(lang) {
const langIcon = iconMap.languageIds[lang.id];
lang.extensions.forEach((ext) => {
const iconName = iconMap.fileExtensions[ext] || langIcon;
if (!iconMap.fileExtensions[ext] && iconName && iconMap.iconDefinitions[iconName]) {
languageMap.fileExtensions[ext] = iconName;
}
});
lang.filenames.forEach((name) => {
const iconName = iconMap.fileNames[name] || langIcon;
if (
!iconMap.fileNames[name] &&
!(name.startsWith('.') && iconMap.fileExtensions[name.substring(1)]) &&
iconName &&
iconMap.iconDefinitions[iconName]
) {
languageMap.fileNames[name] = iconName;
}
});
index += 1;
if (index === total) {
index = 0;
generateLanguageMap();
}
}
async function generateLanguageMap() {
console.log('[6/7] Writing language contribution map to icon configuration file.');
fs.writeFileSync(
path.resolve(srcPath, 'language-map.json'),
stringify(languageMap, { space: ' ' })
);
console.log('[7/7] Deleting language contribution cache.');
await fs.remove(vsDataPath);
}

231
scripts/build-languages.ts Normal file
View File

@ -0,0 +1,231 @@
import * as path from 'path';
import { Octokit } from '@octokit/core';
import * as fs from 'fs-extra';
import stringify from 'json-stable-stringify';
import iconMap from 'material-icon-theme/dist/material-icons.json';
const iconMapTyped: {
languageIds: { [key: string]: string };
fileExtensions: { [key: string]: string };
fileNames: { [key: string]: string };
iconDefinitions: { [key: string]: any };
} = iconMap;
interface LanguageContribution {
id: string;
extensions?: string[];
filenames?: string[];
filenamePatterns?: string[];
}
interface Language {
id: string;
extensions: string[];
filenames: string[];
}
const vsDataPath: string = path.resolve(__dirname, '..', 'data');
const srcPath: string = path.resolve(__dirname, '..', 'src');
let index: number = 0;
let total: number;
const items: Array<[string, string]> = [];
const contributions: LanguageContribution[] = [];
const languages: Language[] = [];
const resultsPerPage: number = 100; // max 100
const octokit: Octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
// biome-ignore lint/style/useNamingConvention: per_page is a valid name
const query: { page: number; per_page: number; q: string } = {
page: 0,
// biome-ignore lint/style/useNamingConvention: per_page is a valid name
per_page: resultsPerPage,
q: 'contributes languages filename:package.json repo:microsoft/vscode',
};
const GITHUB_RATELIMIT: number = 6000;
async function main(): Promise<void> {
await fs.remove(vsDataPath);
await fs.ensureDir(vsDataPath);
await fs.remove(path.resolve(srcPath, 'language-map.json'));
console.log(
'[1/7] Querying Github API for official VSC language contributions.'
);
queryLanguageContributions();
}
main();
async function queryLanguageContributions(): Promise<void> {
const res = await octokit.request('GET /search/code', query);
if (!res.data) throw new Error();
query.page = index;
index += 1;
if (!total) total = res.data.total_count;
items.push(
...res.data.items.map(
(item) => [item.html_url, item.path] as [string, string]
)
);
if (resultsPerPage * index >= total) {
console.log('[2/7] Fetching Microsoft language contributions from Github.');
index = 0;
total = items.length;
items.forEach(([htmlUrl, path]) =>
fetchLanguageContribution(htmlUrl, path)
);
} else {
setTimeout(queryLanguageContributions, GITHUB_RATELIMIT);
}
}
async function fetchLanguageContribution(
htmlUrl: string,
itemPath: string
): Promise<void> {
const rawUrl: string = htmlUrl.replace('/blob/', '/raw/');
const resPath: string = itemPath.replace(/[^/]+$/, 'extension.json');
const extPath: string = path.join(vsDataPath, resPath);
let extManifest: string;
try {
const response = await fetch(rawUrl, {});
extManifest = await response.text();
} catch (reason) {
throw new Error(`${reason}`);
}
try {
await fs.ensureDir(path.dirname(extPath));
await fs.writeFile(extPath, extManifest, 'utf-8');
} catch (reason) {
throw new Error(`${reason} (${extPath})`);
}
items[index] = [extPath, extManifest];
index += 1;
if (index === total) {
console.log('[3/7] Loading VSC language contributions into Node.');
index = 0;
items.forEach(([extPath, extManifest]) =>
loadLanguageContribution(extPath, extManifest)
);
console.log(
'[4/7] Processing language contributions for VSC File Icon API compatibility.'
);
index = 0;
total = contributions.length;
contributions.forEach(processLanguageContribution);
}
}
function loadLanguageContribution(extPath: string, extManifest: string): void {
let data: any;
try {
data = JSON.parse(extManifest.replace(/#\w+_\w+#/g, '0'));
} catch (error) {
throw new Error(`${error} (${extPath})`);
}
if (!data.contributes?.languages) {
return;
}
contributions.push(...data.contributes.languages);
}
function processLanguageContribution(contribution: LanguageContribution): void {
const { id, filenamePatterns } = contribution;
let { extensions, filenames } = contribution;
extensions = extensions || [];
filenames = filenames || [];
if (filenamePatterns) {
filenamePatterns.forEach((ptn) => {
if (/^\*\.[^*/?]+$/.test(ptn)) {
extensions?.push(ptn.substring(1));
}
if (/^[^*/?]+$/.test(ptn)) {
filenames?.push(ptn);
}
});
}
extensions = extensions
.map((ext) => (ext.charAt(0) === '.' ? ext.substring(1) : ext))
.filter((ext) => !/\*|\/|\?/.test(ext));
filenames = filenames.filter((name) => !/\*|\/|\?/.test(name));
if (!filenames.length && !extensions.length) {
total -= 1;
return;
}
const language: Language | undefined = languages.find(
(lang) => lang.id === id
);
if (language) {
language.filenames.push(...filenames);
language.extensions.push(...extensions);
} else {
languages.push({ id, extensions, filenames });
}
index += 1;
if (index === total) {
console.log(
'[5/7] Mapping language contributions into file icon configuration.'
);
index = 0;
total = languages.length;
languages.forEach(mapLanguageContribution);
}
}
const languageMap: {
fileExtensions: { [key: string]: string };
fileNames: { [key: string]: string };
} = {
fileExtensions: {},
fileNames: {},
};
function mapLanguageContribution(lang: Language): void {
// Assuming iconMap is defined elsewhere in the code or imported
const langIcon: string | undefined = iconMapTyped.languageIds[lang.id];
lang.extensions.forEach((ext) => {
const iconName: string | undefined =
iconMapTyped.fileExtensions[ext] || langIcon;
if (
!iconMapTyped.fileExtensions[ext] &&
iconName &&
iconMapTyped.iconDefinitions[iconName]
) {
languageMap.fileExtensions[ext] = iconName;
}
});
lang.filenames.forEach((name) => {
const iconName: string | undefined =
iconMapTyped.fileNames[name] || langIcon;
if (
!iconMapTyped.fileNames[name] &&
!(
name.startsWith('.') && iconMapTyped.fileExtensions[name.substring(1)]
) &&
iconName &&
iconMapTyped.iconDefinitions[iconName]
) {
languageMap.fileNames[name] = iconName;
}
});
index += 1;
if (index === total) {
generateLanguageMap();
}
}
async function generateLanguageMap(): Promise<void> {
console.log(
'[6/7] Writing language contribution map to icon configuration file.'
);
await fs.writeFile(
path.resolve(srcPath, 'language-map.json'),
stringify(languageMap, { space: ' ' })
);
console.log('[7/7] Deleting language contribution cache.');
await fs.remove(vsDataPath);
}

View File

@ -1,98 +0,0 @@
const path = require('path');
const fs = require('fs-extra');
const Parcel = require('parcel-bundler');
const destSVGPath = path.resolve(__dirname, '..', 'svg');
const distBasePath = path.resolve(__dirname, '..', 'dist');
const srcPath = path.resolve(__dirname, '..', 'src');
/** Create icons cache. */
async function consolidateSVGFiles() {
console.log('[1/2] Generate icon cache for extension.');
await fs
.copy(path.resolve(srcPath, 'custom'), destSVGPath)
.then(() => fs.readdir(destSVGPath))
.then((files) => Object.fromEntries(files.map((filename) => [filename, filename])))
.then((iconsDict) => fs.writeJSON(path.resolve(srcPath, 'icon-list.json'), iconsDict));
}
function bundleJS(outDir, entryFile) {
const parcelOptions = {
watch: false,
minify: true,
sourceMaps: false,
outDir,
};
const bundler = new Parcel(entryFile, parcelOptions);
return bundler.bundle();
}
function src(distPath) {
console.log('[2/2] Bundle extension manifest, images and main script.');
const copyIcons = fs.copy(destSVGPath, distPath);
const bundleMainScript = () => bundleJS(distPath, path.resolve(srcPath, 'main.js'));
const bundlePopupScript = () =>
bundleJS(distPath, path.resolve(srcPath, 'ui', 'popup', 'settings-popup.js'));
const bundleOptionsScript = () =>
bundleJS(distPath, path.resolve(srcPath, 'ui', 'options', 'options.js'));
const bundleBackgroundScript = () =>
bundleJS(distPath, path.resolve(srcPath, 'background', 'background.js'));
const bundleAll = bundleMainScript()
.then(bundlePopupScript)
.then(bundleOptionsScript)
.then(bundleBackgroundScript);
const copyPopup = Promise.all(
['settings-popup.html', 'settings-popup.css', 'settings-popup.github-logo.svg'].map((file) =>
fs.copy(path.resolve(srcPath, 'ui', 'popup', file), path.resolve(distPath, file))
)
);
const copyOptions = Promise.all(
['options.html', 'options.css'].map((file) =>
fs.copy(path.resolve(srcPath, 'ui', 'options', file), path.resolve(distPath, file))
)
);
const copyStyles = fs.copy(
path.resolve(srcPath, 'injected-styles.css'),
path.resolve(distPath, 'injected-styles.css')
);
const copyExtensionLogos = fs.copy(path.resolve(srcPath, 'extensionIcons'), distPath);
return Promise.all([
copyExtensionLogos,
copyOptions,
copyPopup,
copyStyles,
copyIcons,
bundleAll,
]);
}
function buildManifest(distPath, manifestName) {
return Promise.all([
fs.readJson(path.resolve(srcPath, 'manifests', 'base.json')),
fs.readJson(path.resolve(srcPath, 'manifests', manifestName)),
])
.then(([base, custom]) => ({ ...base, ...custom }))
.then((manifest) =>
fs.writeJson(path.resolve(distPath, 'manifest.json'), manifest, { spaces: 2 })
);
}
function buildDist(name, manifestName) {
const distPath = path.resolve(distBasePath, name);
return fs
.ensureDir(distPath)
.then(consolidateSVGFiles)
.then(() => src(distPath))
.then(() => buildManifest(distPath, manifestName))
.catch(console.error);
}
buildDist('firefox', 'firefox.json').then(() => buildDist('chrome-edge', 'chrome-edge.json'));

135
scripts/build-src.ts Normal file
View File

@ -0,0 +1,135 @@
import * as path from 'path';
import * as esbuild from 'esbuild';
import * as fs from 'fs-extra';
const destSVGPath: string = path.resolve(
__dirname,
'..',
'node_modules',
'material-icon-theme',
'icons'
);
const distBasePath: string = path.resolve(__dirname, '..', 'dist');
const srcPath: string = path.resolve(__dirname, '..', 'src');
/** Create icons cache. */
async function consolidateSVGFiles(): Promise<void> {
console.log('[1/2] Generate icon cache for extension.');
await fs
.copy(path.resolve(srcPath, 'custom'), destSVGPath)
.then(() => fs.readdir(destSVGPath))
.then((files) =>
Object.fromEntries(files.map((filename) => [filename, filename]))
)
.then((iconsDict) =>
fs.writeJSON(path.resolve(srcPath, 'icon-list.json'), iconsDict)
);
}
function bundleJS(
outDir: string,
entryFile: string
): Promise<esbuild.BuildResult> {
const buildOptions: esbuild.BuildOptions = {
entryPoints: [entryFile],
bundle: true,
minify: true,
sourcemap: false,
outdir: outDir,
};
return esbuild.build(buildOptions);
}
function src(
distPath: string
): Promise<(void | esbuild.BuildResult | void[])[]> {
console.log('[2/2] Bundle extension manifest, images and main script.');
const copyIcons: Promise<void> = fs.copy(destSVGPath, distPath);
const bundleMainScript = (): Promise<esbuild.BuildResult> =>
bundleJS(distPath, path.resolve(srcPath, 'main.ts'));
const bundlePopupScript = (): Promise<esbuild.BuildResult> =>
bundleJS(
distPath,
path.resolve(srcPath, 'ui', 'popup', 'settings-popup.ts')
);
const bundleOptionsScript = (): Promise<esbuild.BuildResult> =>
bundleJS(distPath, path.resolve(srcPath, 'ui', 'options', 'options.ts'));
const bundleBackgroundScript = (): Promise<esbuild.BuildResult> =>
bundleJS(distPath, path.resolve(srcPath, 'background', 'background.ts'));
const bundleAll: Promise<esbuild.BuildResult> = bundleMainScript()
.then(bundlePopupScript)
.then(bundleOptionsScript)
.then(bundleBackgroundScript);
const copyPopup: Promise<void[]> = Promise.all(
[
'settings-popup.html',
'settings-popup.css',
'settings-popup.github-logo.svg',
].map((file) =>
fs.copy(
path.resolve(srcPath, 'ui', 'popup', file),
path.resolve(distPath, file)
)
)
);
const copyOptions: Promise<void[]> = Promise.all(
['options.html', 'options.css'].map((file) =>
fs.copy(
path.resolve(srcPath, 'ui', 'options', file),
path.resolve(distPath, file)
)
)
);
const copyStyles: Promise<void> = fs.copy(
path.resolve(srcPath, 'injected-styles.css'),
path.resolve(distPath, 'injected-styles.css')
);
const copyExtensionLogos: Promise<void> = fs.copy(
path.resolve(srcPath, 'extensionIcons'),
distPath
);
return Promise.all([
copyExtensionLogos,
copyOptions,
copyPopup,
copyStyles,
copyIcons,
bundleAll,
]);
}
function buildManifest(distPath: string, manifestName: string): Promise<void> {
return Promise.all([
fs.readJson(path.resolve(srcPath, 'manifests', 'base.json')),
fs.readJson(path.resolve(srcPath, 'manifests', manifestName)),
])
.then(([base, custom]) => ({ ...base, ...custom }))
.then((manifest) =>
fs.writeJson(path.resolve(distPath, 'manifest.json'), manifest, {
spaces: 2,
})
);
}
function buildDist(name: string, manifestName: string): Promise<void> {
const distPath: string = path.resolve(distBasePath, name);
return fs
.ensureDir(distPath)
.then(consolidateSVGFiles)
.then(() => src(distPath))
.then(() => buildManifest(distPath, manifestName))
.catch(console.error);
}
buildDist('firefox', 'firefox.json').then(() =>
buildDist('chrome-edge', 'chrome-edge.json')
);

View File

@ -1,20 +0,0 @@
/**
* Copies version from package.json into src/manifest.json
*/
const path = require('path');
const fs = require('fs').promises;
const package = require(path.resolve(__dirname, '..', 'package.json'));
const manifestPath = path.resolve(__dirname, '..', 'src', 'manifests', 'base.json');
const manifest = require(manifestPath);
const updatedManifest = { ...manifest, version: package.version };
const updatedManifestStr = `${JSON.stringify(updatedManifest, null, 2)}\n`;
fs.writeFile(manifestPath, updatedManifestStr)
.then(() => {
console.log(`Updated manifest.json version to ${package.version}`);
})
.catch(console.error);

View File

@ -0,0 +1,34 @@
import * as path from 'path';
import * as fs from 'fs/promises';
const packageJsonPath: string = path.resolve(__dirname, '..', 'package.json');
const manifestPath: string = path.resolve(
__dirname,
'..',
'src',
'manifests',
'base.json'
);
const updateManifestVersion = async (): Promise<void> => {
const packageJsonData: string = await fs.readFile(packageJsonPath, {
encoding: 'utf8',
});
const packageJson = JSON.parse(packageJsonData);
const manifestData: string = await fs.readFile(manifestPath, {
encoding: 'utf8',
});
const manifest = JSON.parse(manifestData);
const updatedManifest = {
...manifest,
version: packageJson.version,
};
const updatedManifestStr: string = `${JSON.stringify(updatedManifest, null, 2)}\n`;
await fs.writeFile(manifestPath, updatedManifestStr);
console.log(`Updated manifest.json version to ${packageJson.version}`);
};
updateManifestVersion().catch(console.error);

View File

@ -1,68 +0,0 @@
const fetch = require('node-fetch');
const path = require('path');
const api = require('@octokit/core');
const compareVersions = require('compare-versions');
const fs = require('fs/promises');
const upstreamVersionFilePath = path.resolve(__dirname, '..', 'upstream.version');
const upstreamCommitFilePath = path.resolve(__dirname, '..', 'upstream.commit');
/**
* Gets latest VSCode Extension release version by parsing it's most recent 100 commit msgs
*
* returns version string or undefined
*
* @returns {Promise<string>} The current version of the upstream repository.
*/
const getUpstreamVersion = () =>
fetch('https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/package.json')
.then((res) => res.json())
.then((package) => package.version);
const octokit = new api.Octokit();
const getUpstreamCommit = () =>
octokit
.request('GET /repos/PKief/vscode-material-icon-theme/commits', { per_page: 1 })
.then((res) => res.data?.[0].sha);
const getLastUpstreamVersion = () =>
fs.readFile(upstreamVersionFilePath, { encoding: 'utf8' }).then((data) => data.trim());
const updateReadmeBadge = async (version) => {
const readmeFilePath = path.resolve(__dirname, '..', 'README.md');
const readme = await fs.readFile(readmeFilePath, { encoding: 'utf8' });
const versionRgx = /(badge\/[\w_]+-v)\d+\.\d+\.\d+/;
const replacement = `$1${version}`;
const updatedReadme = readme.replace(versionRgx, replacement);
return fs.writeFile(readmeFilePath, updatedReadme);
};
const run = async () => {
const latestVersion = await getUpstreamVersion();
const lastSeenVersion = await getLastUpstreamVersion();
const latestUpstreamCommit = await getUpstreamCommit();
console.log(`Latest upstream version: ${latestVersion}`);
console.log(`Current repository version: ${lastSeenVersion}`);
if (!latestVersion || compareVersions.compare(lastSeenVersion, latestVersion, '>=')) {
// exit script with an error. Simplifies chaining of shell commands only in case updates are found
console.log('No update necessary.');
process.exit(1);
}
console.log('Updating upstream version in "/upstream.version"');
await fs.writeFile(upstreamVersionFilePath, latestVersion);
console.log('Updating upstream commit sha in "/upstream.commit"');
await fs.writeFile(upstreamCommitFilePath, latestUpstreamCommit);
console.log('Updating upstream version badge in README');
await updateReadmeBadge(latestVersion);
};
run();

View File

@ -0,0 +1,50 @@
import * as path from 'path';
import * as fs from 'fs/promises';
/**
* Gets latest VSCode Extension release version by parsing its most recent 100 commit msgs
*
* @returns {Promise<string>} The current version of the upstream repository.
*/
const getUpstreamVersion = async (): Promise<string> => {
const packagePath: string = path.resolve(
__dirname,
'..',
'node_modules',
'material-icon-theme',
'package.json'
);
const packageData: string = await fs.readFile(packagePath, {
encoding: 'utf8',
});
const packageJson = JSON.parse(packageData);
return packageJson.version;
};
/**
* Updates the version badge in the README file.
*
* @param {string} version - The new version to update the badge to.
* @returns {Promise<void>}
*/
const updateReadmeBadge = async (version: string): Promise<void> => {
const readmeFilePath: string = path.resolve(__dirname, '..', 'README.md');
const readme: string = await fs.readFile(readmeFilePath, {
encoding: 'utf8',
});
const versionRgx: RegExp = /(badge\/[\w_]+-v)\d+\.\d+\.\d+/;
const replacement: string = `$1${version}`;
const updatedReadme: string = readme.replace(versionRgx, replacement);
await fs.writeFile(readmeFilePath, updatedReadme);
};
/**
* Main function to run the update process.
*/
const run = async (): Promise<void> => {
const latestVersion: string = await getUpstreamVersion();
await updateReadmeBadge(latestVersion);
};
run().catch(console.error);

View File

@ -1,13 +1,21 @@
import Browser from 'webextension-polyfill'; import Browser from 'webextension-polyfill';
Browser.runtime.onMessage.addListener((message) => { type Message = {
event: string;
data: {
host: string;
tabId: number;
};
};
Browser.runtime.onMessage.addListener((message: Message) => {
if (message.event === 'request-access') { if (message.event === 'request-access') {
const perm = { const perm: Browser.Permissions.Permissions = {
permissions: ['activeTab'], permissions: ['activeTab'],
origins: [`*://${message.data.host}/*`], origins: [`*://${message.data.host}/*`],
}; };
Browser.permissions.request(perm).then((granted) => { Browser.permissions.request(perm).then((granted: boolean) => {
if (!granted) { if (!granted) {
return; return;
} }

View File

@ -1 +0,0 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m5 2H6v16h12v-9h-7V4z" fill="#42a5f5"/></svg>

Before

Width:  |  Height:  |  Size: 193 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z" fill="#42a5f5" opacity=".745"/><path d="M16.972 10.757v2.641h-6.561v5.281h6.561v2.641l6.562-5.281-6.562-5.282z" opacity=".81" fill="#c5e5fd"/></svg> <svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="M13.84376,7.53645l-1.28749-1.0729A2,2,0,0,0,11.27591,6H4A2,2,0,0,0,2,8V24a2,2,0,0,0,2,2H28a2,2,0,0,0,2-2V10a2,2,0,0,0-2-2H15.12412A2,2,0,0,1,13.84376,7.53645Z" fill="#90a4ae"/><g transform="translate(3.233,3.34)" fill="#eceff1"><path d="m20.767 9.66v4h-8v6h8v4l8-7z" fill="#eceff1" /></g></svg>

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 399 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z" fill="#42a5f5"/></svg>

Before

Width:  |  Height:  |  Size: 195 B

View File

@ -1,10 +0,0 @@
import Browser from 'webextension-polyfill';
export const getCustomProviders = () =>
Browser.storage.sync.get('customProviders').then((data) => data.customProviders || {});
export const addCustomProvider = (name, handler) =>
getCustomProviders().then((customProviders) => {
customProviders[name] = handler;
return Browser.storage.sync.set({ customProviders });
});

View File

@ -0,0 +1,19 @@
import Browser from 'webextension-polyfill';
import { Provider } from '../models';
export const getCustomProviders = (): Promise<
Record<string, (() => Provider) | string>
> =>
Browser.storage.sync
.get('customProviders')
.then((data) => data.customProviders || {});
export const addCustomProvider = (
name: string,
handler: (() => Provider) | string
) =>
getCustomProviders().then((customProviders) => {
customProviders[name] = handler;
return Browser.storage.sync.set({ customProviders });
});

View File

@ -1,6 +1,8 @@
import { getConfig, onConfigChange } from './userConfig'; import { getConfig, onConfigChange } from './user-config';
const setSizeAttribute = (iconSize) => export type IconSize = 'sm' | 'md' | 'lg' | 'xl';
const setSizeAttribute = (iconSize: IconSize) =>
document.body.setAttribute(`data-material-icons-extension-size`, iconSize); document.body.setAttribute(`data-material-icons-extension-size`, iconSize);
export const initIconSizes = () => { export const initIconSizes = () => {

View File

@ -1,238 +0,0 @@
import Browser from 'webextension-polyfill';
import iconsList from '../icon-list.json';
import iconMap from '../icon-map.json';
import languageMap from '../language-map.json';
/**
* A helper function to check if an object has a key value, without including prooerties from the prototype chain.
*
* @see https://eslint.org/docs/latest/rules/no-prototype-builtins
* @param {object} obj An object to check for a key.
* @param {string} key A string represeing a key to find in the object.
* @returns {boolean} Whether the object contains the key or not.
*/
function objectHas(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
/**
* Replace file/folder icons.
*
* @param {HTMLElement} itemRow Item Row.
* @param {object} provider Git Provider specs.
* @param {string | null} iconPack active icon pack. Selectable by user
* @returns {void}
*/
export function replaceIconInRow(itemRow, provider, iconPack) {
// Get file/folder name.
const fileName = itemRow
.querySelector(provider.selectors.filename)
?.innerText?.split('/')[0] // get first part of path for a proper icon lookup
.trim();
if (!fileName) return; // fileName couldn't be found or we don't have a match for it.
// SVG to be replaced.
const iconEl = itemRow.querySelector(provider.selectors.icon);
if (iconEl?.getAttribute('data-material-icons-extension')) return;
if (iconEl) replaceIcon(iconEl, fileName, itemRow, provider, iconPack);
}
function replaceIcon(iconEl, fileName, itemRow, provider, iconPack) {
// Get Directory or Submodule type.
const isDir = provider.getIsDirectory({ row: itemRow, icon: iconEl });
const isSubmodule = provider.getIsSubmodule({ row: itemRow, icon: iconEl });
const isSymlink = provider.getIsSymlink({ row: itemRow, icon: iconEl });
const lowerFileName = fileName.toLowerCase();
// Get file extensions.
const fileExtensions = [];
// Avoid doing an explosive combination of extensions for very long filenames
// (most file systems do not allow files > 255 length) with lots of `.` characters
// https://github.com/microsoft/vscode/issues/116199
if (fileName.length <= 255) {
for (let i = 0; i < fileName.length; i += 1) {
if (fileName[i] === '.') fileExtensions.push(fileName.slice(i + 1));
}
}
// Get icon name.
let iconName = lookForMatch(
fileName,
lowerFileName,
fileExtensions,
isDir,
isSubmodule,
isSymlink
); // returns icon name if found or undefined.
const isLightTheme = provider.getIsLightTheme();
if (isLightTheme) {
iconName = lookForLightMatch(iconName, fileName, fileExtensions, isDir); // returns icon name if found for light mode or undefined.
}
replaceElementWithIcon(iconEl, iconName, fileName, iconPack, provider);
}
export function replaceElementWithIcon(iconEl, iconName, fileName, iconPack, provider) {
// Get folder icon from active icon pack.
const svgFileName = lookForIconPackMatch(iconPack, fileName.toLowerCase()) ?? iconName;
if (!svgFileName) return;
const newSVG = document.createElement('img');
newSVG.setAttribute('data-material-icons-extension', 'icon');
newSVG.setAttribute('data-material-icons-extension-iconname', iconName);
newSVG.setAttribute('data-material-icons-extension-filename', fileName);
newSVG.src = Browser.runtime.getURL(`${svgFileName}.svg`);
provider.replaceIcon(iconEl, newSVG);
}
/**
* Lookup for matched file/folder icon name.
*
* @since 1.0.0
* @param {string} fileName File name.
* @param {string} lowerFileName Lowercase file name.
* @param {string[]} fileExtensions File extensions.
* @param {boolean} isDir Check if directory type.
* @param {boolean} isSubmodule Check if submodule type.
* @param {boolean} isSymlink Check if symlink
* @returns {string} The matched icon name.
*/
function lookForMatch(fileName, lowerFileName, fileExtensions, isDir, isSubmodule, isSymlink) {
if (isSubmodule) return 'folder-git';
if (isSymlink) return 'folder-symlink';
// If it's a file.
if (!isDir) {
// First look in fileNames
if (objectHas(iconMap.fileNames, fileName)) return iconMap.fileNames[fileName];
// Then check all lowercase
if (objectHas(iconMap.fileNames, lowerFileName)) return iconMap.fileNames[lowerFileName];
// Look for extension in fileExtensions and languageIds.
for (const ext of fileExtensions) {
if (objectHas(iconMap.fileExtensions, ext)) return iconMap.fileExtensions[ext];
if (objectHas(iconMap.languageIds, ext)) return iconMap.languageIds[ext];
}
// Look for filename and extension in VSCode language map.
if (objectHas(languageMap.fileNames, fileName)) return languageMap.fileNames[fileName];
if (objectHas(languageMap.fileNames, lowerFileName))
return languageMap.fileNames[lowerFileName];
for (const ext of fileExtensions) {
if (objectHas(languageMap.fileExtensions, ext)) return languageMap.fileExtensions[ext];
}
// Fallback into default file if no matches.
return 'file';
}
// Otherwise, it's a folder.
// First look in folderNames.
if (objectHas(iconMap.folderNames, fileName)) return iconMap.folderNames[fileName];
// Then check all lowercase.
if (objectHas(iconMap.folderNames, lowerFileName)) return iconMap.folderNames[lowerFileName];
// Fallback into default folder if no matches.
return 'folder';
}
/**
* Lookup for matched light file/folder icon name.
*
* @since 1.4.0
* @param {string} iconName Icon name.
* @param {string} fileName File name.
* @param {string[]} fileExtensions File extension.
* @param {boolean} isDir Check if directory or file type.
* @returns {string} The matched icon name.
*/
function lookForLightMatch(iconName, fileName, fileExtensions, isDir) {
// First look in fileNames and folderNames.
if (iconMap.light.fileNames[fileName] && !isDir) return iconMap.light.fileNames[fileName];
if (iconMap.light.folderNames[fileName] && isDir) return iconMap.light.folderNames[fileName];
// Look for extension in fileExtensions and languageIds.
for (const ext of fileExtensions) {
if (iconMap.light.fileExtensions[ext] && !isDir) return iconMap.light.fileExtensions[ext];
}
return iconName;
}
/**
* Lookup for matched icon from active icon pack.
*
* @since 1.4.0
* @param {string | null} iconPack active icon pack. Selectable by user
* @param {string} lowerFileName Lowercase file name.
* @returns {string | null} The matched icon name.
*/
function lookForIconPackMatch(iconPack, lowerFileName) {
if (!iconPack) return null;
switch (iconPack) {
case 'angular':
if (objectHas(iconsList, `folder-angular-${lowerFileName}.svg`))
return `folder-angular-${lowerFileName}`;
break;
case 'angular_ngrx':
if (objectHas(iconsList, `folder-ngrx-${lowerFileName}.svg`))
return `folder-ngrx-${lowerFileName}`;
if (objectHas(iconsList, `folder-angular-${lowerFileName}.svg`))
return `folder-angular-${lowerFileName}`;
break;
case 'react':
case 'react_redux':
if (objectHas(iconsList, `folder-react-${lowerFileName}.svg`)) {
return `folder-react-${lowerFileName}`;
}
if (objectHas(iconsList, `folder-redux-${lowerFileName}.svg`)) {
return `folder-redux-${lowerFileName}`;
}
break;
case 'vue':
case 'vue_vuex':
if (objectHas(iconsList, `folder-vuex-${lowerFileName}.svg`)) {
return `folder-vuex-${lowerFileName}`;
}
if (objectHas(iconsList, `folder-vue-${lowerFileName}.svg`)) {
return `folder-vue-${lowerFileName}`;
}
if (lowerFileName === 'nuxt') {
return `folder-nuxt`;
}
break;
case 'nest':
switch (true) {
case /\.controller\.(t|j)s$/.test(lowerFileName):
return `nest-controller`;
case /\.middleware\.(t|j)s$/.test(lowerFileName):
return 'nest-middleware';
case /\.module\.(t|j)s$/.test(lowerFileName):
return 'nest-module';
case /\.service\.(t|j)s$/.test(lowerFileName):
return 'nest-service';
case /\.decorator\.(t|j)s$/.test(lowerFileName):
return 'nest-decorator';
case /\.pipe\.(t|j)s$/.test(lowerFileName):
return 'nest-pipe';
case /\.filter\.(t|j)s$/.test(lowerFileName):
return 'nest-filter';
case /\.gateway\.(t|j)s$/.test(lowerFileName):
return 'nest-gateway';
case /\.guard\.(t|j)s$/.test(lowerFileName):
return 'nest-guard';
case /\.resolver\.(t|j)s$/.test(lowerFileName):
return 'nest-resolver';
default:
return null;
}
default:
return null;
}
return null;
}

208
src/lib/replace-icon.ts Normal file
View File

@ -0,0 +1,208 @@
import iconMap from 'material-icon-theme/dist/material-icons.json';
import Browser from 'webextension-polyfill';
import iconsList from '../icon-list.json';
import languageMap from '../language-map.json';
import { Provider } from '../models';
const iconMapTyped = iconMap as {
fileNames: Record<string, string>;
folderNames: Record<string, string>;
fileExtensions: Record<string, string>;
languageIds: Record<string, string>;
light: {
fileNames: Record<string, string>;
folderNames: Record<string, string>;
fileExtensions: Record<string, string>;
};
};
const iconsListTyped = iconsList as Record<string, string>;
const languageMapTyped = languageMap as {
fileExtensions: Record<string, string>;
fileNames: Record<string, string>;
};
export function replaceIconInRow(
itemRow: HTMLElement,
provider: Provider,
iconPack: string | null
): void {
const fileName = itemRow
.querySelector(provider.selectors.filename)
?.textContent?.split('/')[0]
.trim();
if (!fileName) return;
const iconEl = itemRow.querySelector(
provider.selectors.icon
) as HTMLElement | null;
if (iconEl?.getAttribute('data-material-icons-extension')) return;
if (iconEl) replaceIcon(iconEl, fileName, itemRow, provider, iconPack);
}
function replaceIcon(
iconEl: HTMLElement,
fileName: string,
itemRow: HTMLElement,
provider: Provider,
iconPack: string | null
): void {
const isDir = provider.getIsDirectory({ row: itemRow, icon: iconEl });
const isSubmodule = provider.getIsSubmodule({ row: itemRow, icon: iconEl });
const isSymlink = provider.getIsSymlink({ row: itemRow, icon: iconEl });
const lowerFileName = fileName.toLowerCase();
const fileExtensions: string[] = [];
if (fileName.length <= 255) {
for (let i = 0; i < fileName.length; i += 1) {
if (fileName[i] === '.') fileExtensions.push(fileName.slice(i + 1));
}
}
let iconName = lookForMatch(
fileName,
lowerFileName,
fileExtensions,
isDir,
isSubmodule,
isSymlink
);
const isLightTheme = provider.getIsLightTheme();
if (isLightTheme) {
iconName = lookForLightMatch(iconName, fileName, fileExtensions, isDir);
}
replaceElementWithIcon(iconEl, iconName, fileName, iconPack, provider);
}
export function replaceElementWithIcon(
iconEl: HTMLElement,
iconName: string | undefined,
fileName: string,
iconPack: string | null,
provider: Provider
): void {
const svgFileName =
lookForIconPackMatch(iconPack, fileName.toLowerCase()) ?? iconName;
if (!svgFileName) return;
const newSVG = document.createElement('img');
newSVG.setAttribute('data-material-icons-extension', 'icon');
newSVG.setAttribute('data-material-icons-extension-iconname', iconName ?? '');
newSVG.setAttribute('data-material-icons-extension-filename', fileName);
newSVG.src = Browser.runtime.getURL(`${svgFileName}.svg`);
provider.replaceIcon(iconEl, newSVG);
}
function lookForMatch(
fileName: string,
lowerFileName: string,
fileExtensions: string[],
isDir: boolean,
isSubmodule: boolean,
isSymlink: boolean
): string | undefined {
if (isSubmodule) return 'folder-git';
if (isSymlink) return 'folder-symlink';
if (!isDir) {
if (iconMapTyped.fileNames[fileName])
return iconMapTyped.fileNames[fileName];
if (iconMapTyped.fileNames[lowerFileName])
return iconMapTyped.fileNames[lowerFileName];
for (const ext of fileExtensions) {
if (iconMapTyped.fileExtensions[ext])
return iconMapTyped.fileExtensions[ext];
if (iconMapTyped.languageIds[ext]) return iconMapTyped.languageIds[ext];
}
if (languageMapTyped.fileNames[fileName])
return languageMapTyped.fileNames[fileName];
if (languageMapTyped.fileNames[lowerFileName])
return languageMapTyped.fileNames[lowerFileName];
for (const ext of fileExtensions) {
if (languageMapTyped.fileExtensions[ext])
return languageMapTyped.fileExtensions[ext];
}
return 'file';
}
if (iconMapTyped.folderNames[fileName])
return iconMapTyped.folderNames[fileName];
if (iconMapTyped.folderNames[lowerFileName])
return iconMapTyped.folderNames[lowerFileName];
return 'folder';
}
function lookForLightMatch(
iconName: string | undefined,
fileName: string,
fileExtensions: string[],
isDir: boolean
): string | undefined {
if (iconMapTyped.light.fileNames[fileName] && !isDir)
return iconMapTyped.light.fileNames[fileName];
if (iconMapTyped.light.folderNames[fileName] && isDir)
return iconMapTyped.light.folderNames[fileName];
for (const ext of fileExtensions) {
if (iconMapTyped.light.fileExtensions[ext] && !isDir)
return iconMapTyped.light.fileExtensions[ext];
}
return iconName;
}
function lookForIconPackMatch(
iconPack: string | null,
lowerFileName: string
): string | null {
if (!iconPack) return null;
switch (iconPack) {
case 'angular':
if (iconsListTyped[`folder-angular-${lowerFileName}.svg`])
return `folder-angular-${lowerFileName}`;
break;
case 'angular_ngrx':
if (iconsListTyped[`folder-ngrx-${lowerFileName}.svg`])
return `folder-ngrx-${lowerFileName}`;
if (iconsListTyped[`folder-angular-${lowerFileName}.svg`])
return `folder-angular-${lowerFileName}`;
break;
case 'react':
case 'react_redux':
if (iconsListTyped[`folder-react-${lowerFileName}.svg`])
return `folder-react-${lowerFileName}`;
if (iconsListTyped[`folder-redux-${lowerFileName}.svg`])
return `folder-redux-${lowerFileName}`;
break;
case 'vue':
case 'vue_vuex':
if (iconsListTyped[`folder-vuex-${lowerFileName}.svg`])
return `folder-vuex-${lowerFileName}`;
if (iconsListTyped[`folder-vue-${lowerFileName}.svg`])
return `folder-vue-${lowerFileName}`;
if (lowerFileName === 'nuxt') return `folder-nuxt`;
break;
case 'nest':
if (/\.controller\.(t|j)s$/.test(lowerFileName)) return `nest-controller`;
if (/\.middleware\.(t|j)s$/.test(lowerFileName)) return 'nest-middleware';
if (/\.module\.(t|j)s$/.test(lowerFileName)) return 'nest-module';
if (/\.service\.(t|j)s$/.test(lowerFileName)) return 'nest-service';
if (/\.decorator\.(t|j)s$/.test(lowerFileName)) return 'nest-decorator';
if (/\.pipe\.(t|j)s$/.test(lowerFileName)) return 'nest-pipe';
if (/\.filter\.(t|j)s$/.test(lowerFileName)) return 'nest-filter';
if (/\.gateway\.(t|j)s$/.test(lowerFileName)) return 'nest-gateway';
if (/\.guard\.(t|j)s$/.test(lowerFileName)) return 'nest-guard';
if (/\.resolver\.(t|j)s$/.test(lowerFileName)) return 'nest-resolver';
return null;
default:
return null;
}
return null;
}

View File

@ -1,42 +0,0 @@
import { observe } from 'selector-observer';
import { replaceIconInRow, replaceElementWithIcon } from './replace-icon';
// replacing all icons synchronously prevents visual "blinks" but can
// cause missing icons/rendering delay in very large folders
// replacing asynchronously instead fixes problems in large folders, but introduces "blinks"
// Here we compromise, rushing the first n replacements to prevent blinks that will likely be "above the fold"
// and delaying the replacement of subsequent rows
let executions = 0;
let timerID;
const rushFirst = (rushBatch, callback) => {
if (executions <= rushBatch) {
callback(); // immediately run to prevent visual "blink"
setTimeout(callback, 20); // run again later to catch any icons that are missed in large repositories
executions += 1;
} else {
setTimeout(callback, 0); // run without blocking to prevent delayed rendering of large folders too much
clearTimeout(timerID);
timerID = setTimeout(() => {
executions = 0;
}, 1000); // reset execution tracker
}
};
// Monitor DOM elements that match a CSS selector.
export const observePage = (gitProvider, iconPack) => {
observe(gitProvider.selectors.row, {
add(row) {
const callback = () => replaceIconInRow(row, gitProvider, iconPack);
rushFirst(90, callback);
gitProvider.onAdd(row, callback);
},
});
};
export const replaceAllIcons = (provider, iconPack) =>
document.querySelectorAll('img[data-material-icons-extension-iconname]').forEach((iconEl) => {
const iconName = iconEl.getAttribute('data-material-icons-extension-iconname');
const fileName = iconEl.getAttribute('data-material-icons-extension-filename');
if (iconName) replaceElementWithIcon(iconEl, iconName, fileName, iconPack, provider);
});

52
src/lib/replace-icons.ts Normal file
View File

@ -0,0 +1,52 @@
import { observe } from 'selector-observer';
import { Provider } from '../models';
import { replaceElementWithIcon, replaceIconInRow } from './replace-icon';
let executions = 0;
let timerID: ReturnType<typeof setTimeout> | null = null;
const rushFirst = (rushBatch: number, callback: () => void): void => {
if (executions <= rushBatch) {
callback();
setTimeout(callback, 20);
executions += 1;
} else {
setTimeout(callback, 0);
if (timerID !== null) {
clearTimeout(timerID);
}
timerID = setTimeout(() => {
executions = 0;
}, 1000);
}
};
export const observePage = (gitProvider: Provider, iconPack: string): void => {
observe(gitProvider.selectors.row, {
add(row) {
const callback = () =>
replaceIconInRow(row as HTMLElement, gitProvider, iconPack);
rushFirst(90, callback);
gitProvider.onAdd(row as HTMLElement, callback);
},
});
};
export const replaceAllIcons = (provider: Provider, iconPack: string) =>
document
.querySelectorAll('img[data-material-icons-extension-iconname]')
.forEach((iconEl) => {
const iconName = iconEl.getAttribute(
'data-material-icons-extension-iconname'
);
const fileName =
iconEl.getAttribute('data-material-icons-extension-filename') ?? '';
if (iconName)
replaceElementWithIcon(
iconEl as HTMLElement,
iconName,
fileName,
iconPack,
provider
);
});

57
src/lib/user-config.ts Normal file
View File

@ -0,0 +1,57 @@
import Browser from 'webextension-polyfill';
export type UserConfig = {
iconPack: string;
iconSize: string;
extEnabled: boolean;
};
const hardDefaults: UserConfig = {
iconPack: 'react',
iconSize: 'md',
extEnabled: true,
};
export const getConfig = (
configName: keyof UserConfig,
domain = window.location.hostname,
useDefault = true
) =>
Browser.storage.sync
.get({
// get custom domain config (if not getting default).
[`${domain !== 'default' ? domain : 'SKIP'}:${configName}`]: null,
// also get user default as fallback
[`default:${configName}`]: hardDefaults[configName],
})
.then(
({
[`${domain}:${configName}`]: value,
[`default:${configName}`]: fallback,
}) => value ?? (useDefault ? fallback : null)
);
export const setConfig = (
configName: keyof UserConfig,
value: unknown,
domain = window.location.hostname
) =>
Browser.storage.sync.set({
[`${domain}:${configName}`]: value,
});
export const clearConfig = (
configName: keyof UserConfig,
domain = window.location.hostname
) => Browser.storage.sync.remove(`${domain}:${configName}`);
export const onConfigChange = (
configName: keyof UserConfig,
handler: Function,
domain = window.location.hostname
) =>
Browser.storage.onChanged.addListener(
(changes) =>
changes[`${domain}:${configName}`]?.newValue !== undefined &&
handler(changes[`${domain}:${configName}`]?.newValue)
);

View File

@ -1,35 +0,0 @@
import Browser from 'webextension-polyfill';
const hardDefaults = {
iconPack: 'react',
iconSize: 'md',
extEnabled: true,
};
export const getConfig = (config, domain = window.location.hostname, useDefault = true) =>
Browser.storage.sync
.get({
// get custom domain config (if not getting default).
[`${domain !== 'default' ? domain : 'SKIP'}:${config}`]: null,
// also get user default as fallback
[`default:${config}`]: hardDefaults[config],
})
.then(
({ [`${domain}:${config}`]: value, [`default:${config}`]: fallback }) =>
value ?? (useDefault ? fallback : null)
);
export const setConfig = (config, value, domain = window.location.hostname) =>
Browser.storage.sync.set({
[`${domain}:${config}`]: value,
});
export const clearConfig = (config, domain = window.location.hostname) =>
Browser.storage.sync.remove(`${domain}:${config}`);
export const onConfigChange = (config, handler, domain = window.location.hostname) =>
Browser.storage.onChanged.addListener(
(changes) =>
changes[`${domain}:${config}`]?.newValue !== undefined &&
handler(changes[`${domain}:${config}`]?.newValue)
);

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
/**
* A helper function to check if an object has a key value, without including prooerties from the prototype chain.
*/
export function objectHas(obj: object, key: string) {
return obj.hasOwnProperty(key);
}

View File

@ -1,49 +0,0 @@
import Browser from 'webextension-polyfill';
import { getGitProvider } from './providers';
import { observePage, replaceAllIcons } from './lib/replace-icons';
import { initIconSizes } from './lib/icon-sizes';
import { getConfig, onConfigChange } from './lib/userConfig';
function init() {
initIconSizes();
const { href } = window.location;
getGitProvider(href).then((gitProvider) => {
Promise.all([
getConfig('iconPack'),
getConfig('extEnabled'),
getConfig('extEnabled', 'default'),
]).then(([iconPack, extEnabled, globalExtEnabled]) => {
if (!globalExtEnabled || !extEnabled || !gitProvider) return;
observePage(gitProvider, iconPack);
onConfigChange('iconPack', (newIconPack) => replaceAllIcons(gitProvider, newIconPack));
});
});
}
const handlers = {
init,
guessProvider(possibilities) {
for (const [name, selector] of Object.entries(possibilities)) {
if (document.querySelector(selector)) {
return name;
}
}
return null;
},
};
Browser.runtime.onMessage.addListener((message, sender, response) => {
if (!handlers[message.cmd]) {
return response(null);
}
const result = handlers[message.cmd].apply(null, message.args || []);
return response(result);
});
init();

79
src/main.ts Normal file
View File

@ -0,0 +1,79 @@
import Browser from 'webextension-polyfill';
import { initIconSizes } from './lib/icon-sizes';
import { observePage, replaceAllIcons } from './lib/replace-icons';
import { getConfig, onConfigChange } from './lib/user-config';
import { Provider } from './models';
import { getGitProvider } from './providers';
interface Possibilities {
[key: string]: string;
}
const init = (): void => {
initIconSizes();
const { href } = window.location;
getGitProvider(href).then((Provider: Provider | null) => {
Promise.all([
getConfig('iconPack'),
getConfig('extEnabled'),
getConfig('extEnabled', 'default'),
]).then(
([iconPack, extEnabled, globalExtEnabled]: [
string,
boolean,
boolean,
]) => {
if (!globalExtEnabled || !extEnabled || !Provider) return;
observePage(Provider, iconPack);
onConfigChange('iconPack', (newIconPack: string) =>
replaceAllIcons(Provider, newIconPack)
);
}
);
});
};
type Handlers = {
init: () => void;
guessProvider: (possibilities: Possibilities) => string | null;
};
const handlers: Handlers = {
init,
guessProvider: (possibilities: Possibilities): string | null => {
for (const [name, selector] of Object.entries(possibilities)) {
if (document.querySelector(selector)) {
return name;
}
}
return null;
},
};
Browser.runtime.onMessage.addListener(
(
message: { cmd: keyof Handlers; args?: any[] },
_: Browser.Runtime.MessageSender,
sendResponse: (response?: any) => void
) => {
if (!handlers[message.cmd]) {
return sendResponse(null);
}
if (message.cmd === 'init') {
handlers.init();
return sendResponse(null);
}
if (message.cmd === 'guessProvider') {
const result = handlers[message.cmd](
(message.args || []) as unknown as Possibilities
);
return sendResponse(result);
}
}
);
init();

1
src/models/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './provider';

18
src/models/provider.ts Normal file
View File

@ -0,0 +1,18 @@
export interface Provider {
name: string;
domains: { host: string; test: RegExp }[];
selectors: {
filename: string;
icon: string;
row: string;
detect: string | null;
};
canSelfHost: boolean;
isCustom: boolean;
onAdd: (row: HTMLElement, callback: () => void) => void;
getIsDirectory: (params: { row: HTMLElement; icon: HTMLElement }) => boolean;
getIsSubmodule: (params: { row: HTMLElement; icon: HTMLElement }) => boolean;
getIsSymlink: (params: { row: HTMLElement; icon: HTMLElement }) => boolean;
getIsLightTheme: () => boolean;
replaceIcon: (oldIcon: HTMLElement, newIcon: HTMLElement) => void;
}

View File

@ -1,7 +1,9 @@
import { Provider } from '../models';
/** The name of the class used to hide the pseudo element `:before` on Azure */ /** The name of the class used to hide the pseudo element `:before` on Azure */
const HIDE_PSEUDO_CLASS = 'material-icons-exension-hide-pseudo'; const HIDE_PSEUDO_CLASS = 'material-icons-exension-hide-pseudo';
export default function azure() { export default function azure(): Provider {
return { return {
name: 'azure', name: 'azure',
domains: [ domains: [
@ -25,11 +27,13 @@ export default function azure() {
canSelfHost: false, canSelfHost: false,
isCustom: false, isCustom: false,
getIsLightTheme: () => getIsLightTheme: () =>
document.defaultView.getComputedStyle(document.body).getPropertyValue('color') === document.defaultView
'rgba(0, 0, 0, 0.9)', // TODO: There is probably a better way to determine whether Azure is in light mode ?.getComputedStyle(document.body)
.getPropertyValue('color') === 'rgba(0, 0, 0, 0.9)', // TODO: There is probably a better way to determine whether Azure is in light mode
getIsDirectory: ({ icon }) => icon.classList.contains('repos-folder-icon'), getIsDirectory: ({ icon }) => icon.classList.contains('repos-folder-icon'),
getIsSubmodule: () => false, // There appears to be no way to tell if a folder is a submodule getIsSubmodule: () => false, // There appears to be no way to tell if a folder is a submodule
getIsSymlink: ({ icon }) => icon.classList.contains('ms-Icon--PageArrowRight'), getIsSymlink: ({ icon }) =>
icon.classList.contains('ms-Icon--PageArrowRight'),
replaceIcon: (svgEl, newSVG) => { replaceIcon: (svgEl, newSVG) => {
newSVG.style.display = 'inline-flex'; newSVG.style.display = 'inline-flex';
newSVG.style.height = '1rem'; newSVG.style.height = '1rem';
@ -41,7 +45,7 @@ export default function azure() {
// Instead of replacing the child icon, add the new icon as a child, // Instead of replacing the child icon, add the new icon as a child,
// otherwise Azure DevOps crashes when you navigate through the repository // otherwise Azure DevOps crashes when you navigate through the repository
if (svgEl.hasChildNodes()) { if (svgEl.hasChildNodes() && svgEl.firstChild !== null) {
svgEl.replaceChild(newSVG, svgEl.firstChild); svgEl.replaceChild(newSVG, svgEl.firstChild);
} else { } else {
svgEl.appendChild(newSVG); svgEl.appendChild(newSVG);
@ -51,11 +55,13 @@ export default function azure() {
// Mutation observer is required for azure to work properly because the rows are not removed // Mutation observer is required for azure to work properly because the rows are not removed
// from the page when navigating through the repository. Without this the page will render // from the page when navigating through the repository. Without this the page will render
// fine initially but any subsequent changes will reult in inaccurate icons. // fine initially but any subsequent changes will reult in inaccurate icons.
const mutationCallback = (mutationsList) => { const mutationCallback = (mutationsList: MutationRecord[]) => {
// Check whether the mutation was made by this extension // Check whether the mutation was made by this extension
// this is determined by whether there is an image node added to the dom // this is determined by whether there is an image node added to the dom
const isExtensionMutation = mutationsList.some((mutation) => const isExtensionMutation = mutationsList.some((mutation) =>
Array.from(mutation.addedNodes).some((node) => node.nodeName === 'IMG') Array.from(mutation.addedNodes).some(
(node) => node.nodeName === 'IMG'
)
); );
// If the mutation was not caused by the extension, run the icon replacement // If the mutation was not caused by the extension, run the icon replacement
@ -66,7 +72,11 @@ export default function azure() {
}; };
const observer = new MutationObserver(mutationCallback); const observer = new MutationObserver(mutationCallback);
observer.observe(row, { attributes: true, childList: true, subtree: true }); observer.observe(row, {
attributes: true,
childList: true,
subtree: true,
});
}, },
}; };
} }

View File

@ -1,4 +1,6 @@
export default function bitbucket() { import { Provider } from '../models';
export default function bitbucket(): Provider {
return { return {
name: 'bitbucket', name: 'bitbucket',
domains: [ domains: [
@ -18,8 +20,12 @@ export default function bitbucket() {
canSelfHost: true, canSelfHost: true,
isCustom: false, isCustom: false,
getIsLightTheme: () => true, // No dark mode available for bitbucket currently getIsLightTheme: () => true, // No dark mode available for bitbucket currently
getIsDirectory: ({ icon }) => icon.parentNode?.getAttribute('aria-label') === 'Directory,', getIsDirectory: ({ icon }) =>
getIsSubmodule: ({ icon }) => icon.parentNode?.getAttribute('aria-label') === 'Submodule,', (icon.parentNode as HTMLElement)?.getAttribute('aria-label') ===
'Directory,',
getIsSubmodule: ({ icon }) =>
(icon.parentNode as HTMLElement)?.getAttribute('aria-label') ===
'Submodule,',
getIsSymlink: () => false, // There appears to be no way to determine this for bitbucket getIsSymlink: () => false, // There appears to be no way to determine this for bitbucket
replaceIcon: (svgEl, newSVG) => { replaceIcon: (svgEl, newSVG) => {
newSVG.style.overflow = 'hidden'; newSVG.style.overflow = 'hidden';
@ -34,10 +40,10 @@ export default function bitbucket() {
(attr) => (attr) =>
attr !== 'src' && attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) && !/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr)) newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '')
); );
svgEl.parentNode.replaceChild(newSVG, svgEl); svgEl.parentNode?.replaceChild(newSVG, svgEl);
}, },
onAdd: () => {}, onAdd: () => {},
}; };

View File

@ -1,4 +1,6 @@
export default function gitea() { import { Provider } from '../models';
export default function gitea(): Provider {
return { return {
name: 'gitea', name: 'gitea',
domains: [ domains: [
@ -17,9 +19,12 @@ export default function gitea() {
canSelfHost: true, canSelfHost: true,
isCustom: false, isCustom: false,
getIsLightTheme: () => false, getIsLightTheme: () => false,
getIsDirectory: ({ icon }) => icon.classList.contains('octicon-file-directory-fill'), getIsDirectory: ({ icon }) =>
getIsSubmodule: ({ icon }) => icon.classList.contains('octicon-file-submodule'), icon.classList.contains('octicon-file-directory-fill'),
getIsSymlink: ({ icon }) => icon.classList.contains('octicon-file-symlink-file'), getIsSubmodule: ({ icon }) =>
icon.classList.contains('octicon-file-submodule'),
getIsSymlink: ({ icon }) =>
icon.classList.contains('octicon-file-symlink-file'),
replaceIcon: (svgEl, newSVG) => { replaceIcon: (svgEl, newSVG) => {
svgEl svgEl
.getAttributeNames() .getAttributeNames()
@ -27,10 +32,10 @@ export default function gitea() {
(attr) => (attr) =>
attr !== 'src' && attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) && !/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr)) newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '')
); );
svgEl.parentNode.replaceChild(newSVG, svgEl); svgEl.parentNode?.replaceChild(newSVG, svgEl);
}, },
onAdd: () => {}, onAdd: () => {},
}; };

View File

@ -1,4 +1,6 @@
export default function gitee() { import { Provider } from '../models';
export default function gitee(): Provider {
return { return {
name: 'gitee', name: 'gitee',
domains: [ domains: [
@ -11,7 +13,8 @@ export default function gitee() {
// File list row, README header, file view header // File list row, README header, file view header
row: '#git-project-content .tree-content .row.tree-item, .file_title, .blob-description', row: '#git-project-content .tree-content .row.tree-item, .file_title, .blob-description',
// File name table cell, Submodule name table cell, file view header // File name table cell, Submodule name table cell, file view header
filename: '.tree-list-item > a, .tree-item-submodule-name a, span.file_name', filename:
'.tree-list-item > a, .tree-item-submodule-name a, span.file_name',
// The iconfont icon not including the delete button icon in the file view header // The iconfont icon not including the delete button icon in the file view header
icon: 'i.iconfont:not(.icon-delete)', icon: 'i.iconfont:not(.icon-delete)',
// Element by which to detect if the tested domain is gitee. // Element by which to detect if the tested domain is gitee.
@ -30,13 +33,13 @@ export default function gitee() {
(attr) => (attr) =>
attr !== 'src' && attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) && !/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr)) newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '')
); );
newSVG.style.height = '28px'; newSVG.style.height = '28px';
newSVG.style.width = '18px'; newSVG.style.width = '18px';
svgEl.parentNode.replaceChild(newSVG, svgEl); svgEl.parentNode?.replaceChild(newSVG, svgEl);
}, },
onAdd: () => {}, onAdd: () => {},
}; };

View File

@ -1,4 +1,6 @@
export default function github() { import { Provider } from '../models';
export default function github(): Provider {
return { return {
name: 'github', name: 'github',
domains: [ domains: [
@ -29,14 +31,17 @@ export default function github() {
canSelfHost: true, canSelfHost: true,
isCustom: false, isCustom: false,
getIsLightTheme: () => getIsLightTheme: () =>
document.querySelector('html').getAttribute('data-color-mode') === 'light', document.querySelector('html')?.getAttribute('data-color-mode') ===
'light',
getIsDirectory: ({ icon }) => getIsDirectory: ({ icon }) =>
icon.getAttribute('aria-label') === 'Directory' || icon.getAttribute('aria-label') === 'Directory' ||
icon.classList.contains('octicon-file-directory-fill') || icon.classList.contains('octicon-file-directory-fill') ||
icon.classList.contains('octicon-file-directory-open-fill') || icon.classList.contains('octicon-file-directory-open-fill') ||
icon.classList.contains('icon-directory'), icon.classList.contains('icon-directory'),
getIsSubmodule: ({ icon }) => icon.getAttribute('aria-label') === 'Submodule', getIsSubmodule: ({ icon }) =>
getIsSymlink: ({ icon }) => icon.getAttribute('aria-label') === 'Symlink Directory', icon.getAttribute('aria-label') === 'Submodule',
getIsSymlink: ({ icon }) =>
icon.getAttribute('aria-label') === 'Symlink Directory',
replaceIcon: (svgEl, newSVG) => { replaceIcon: (svgEl, newSVG) => {
svgEl svgEl
.getAttributeNames() .getAttributeNames()
@ -44,7 +49,7 @@ export default function github() {
(attr) => (attr) =>
attr !== 'src' && attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) && !/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr)) newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '')
); );
const prevEl = svgEl.previousElementSibling; const prevEl = svgEl.previousElementSibling;

View File

@ -1,4 +1,6 @@
export default function gitlab() { import { Provider } from '../models';
export default function gitlab(): Provider {
return { return {
name: 'gitlab', name: 'gitlab',
domains: [ domains: [
@ -20,11 +22,14 @@ export default function gitlab() {
}, },
canSelfHost: true, canSelfHost: true,
isCustom: false, isCustom: false,
getIsLightTheme: () => !document.querySelector('body').classList.contains('gl-dark'), getIsLightTheme: () =>
getIsDirectory: ({ icon }) => icon.getAttribute('data-testid') === 'folder-icon', !document.querySelector('body')?.classList.contains('gl-dark'),
getIsDirectory: ({ icon }) =>
icon.getAttribute('data-testid') === 'folder-icon',
getIsSubmodule: ({ row }) => getIsSubmodule: ({ row }) =>
row.querySelector('a')?.classList.contains('is-submodule') || false, row.querySelector('a')?.classList.contains('is-submodule') || false,
getIsSymlink: ({ icon }) => icon.getAttribute('data-testid') === 'symlink-icon', getIsSymlink: ({ icon }) =>
icon.getAttribute('data-testid') === 'symlink-icon',
replaceIcon: (svgEl, newSVG) => { replaceIcon: (svgEl, newSVG) => {
svgEl svgEl
.getAttributeNames() .getAttributeNames()
@ -32,13 +37,13 @@ export default function gitlab() {
(attr) => (attr) =>
attr !== 'src' && attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) && !/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr)) newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '')
); );
newSVG.style.height = '16px'; newSVG.style.height = '16px';
newSVG.style.width = '16px'; newSVG.style.width = '16px';
svgEl.parentNode.replaceChild(newSVG, svgEl); svgEl.parentNode?.replaceChild(newSVG, svgEl);
}, },
onAdd: () => {}, onAdd: () => {},
}; };

View File

@ -1,13 +1,14 @@
import github from './github';
import bitbucket from './bitbucket';
import azure from './azure';
import gitea from './gitea';
import gitlab from './gitlab';
import gitee from './gitee';
import sourceforge from './sourceforge';
import { getCustomProviders } from '../lib/custom-providers'; import { getCustomProviders } from '../lib/custom-providers';
import { Provider } from '../models';
import azure from './azure';
import bitbucket from './bitbucket';
import gitea from './gitea';
import gitee from './gitee';
import github from './github';
import gitlab from './gitlab';
import sourceforge from './sourceforge';
export const providers = { export const providers: Record<string, () => Provider> = {
azure, azure,
bitbucket, bitbucket,
gitea, gitea,
@ -17,7 +18,7 @@ export const providers = {
sourceforge, sourceforge,
}; };
export const providerConfig = {}; export const providerConfig: Record<string, Provider> = {};
for (const provider of Object.values(providers)) { for (const provider of Object.values(providers)) {
const cfg = provider(); const cfg = provider();
@ -25,17 +26,17 @@ for (const provider of Object.values(providers)) {
providerConfig[cfg.name] = cfg; providerConfig[cfg.name] = cfg;
} }
function regExpEscape(str) { function regExpEscape(value: string) {
return str.replace(/[-[\]{}()*+!<=:?./\\^$|#\s,]/g, '\\$&'); return value.replace(/[-[\]{}()*+!<=:?./\\^$|#\s,]/g, '\\$&');
} }
/** /**
* Add custom git provider * Add custom git provider
*
* @param {string} name
* @param {string|CallableFunction} handler
*/ */
export const addGitProvider = (name, handler) => { export const addGitProvider = (
name: string,
handler: (() => Provider) | string
) => {
handler = typeof handler === 'string' ? providers[handler] : handler; handler = typeof handler === 'string' ? providers[handler] : handler;
const provider = handler(); const provider = handler();
@ -64,12 +65,8 @@ export const getGitProviders = () =>
/** /**
* Get all selectors and functions specific to the Git provider * Get all selectors and functions specific to the Git provider
*
* @param {string} href Url of current tab
* @param domain
* @returns {object} All of the values needed for the provider
*/ */
export const getGitProvider = (domain) => { export const getGitProvider = (domain: string) => {
if (!domain.startsWith('http')) { if (!domain.startsWith('http')) {
domain = new URL(`http://${domain}`).host; domain = new URL(`http://${domain}`).host;
} else { } else {

View File

@ -1,4 +1,6 @@
export default function sourceforge() { import { Provider } from '../models';
export default function sourceforge(): Provider {
return { return {
name: 'sourceforge', name: 'sourceforge',
domains: [ domains: [
@ -42,11 +44,15 @@ export default function sourceforge() {
newSVG.style.height = '14px'; newSVG.style.height = '14px';
newSVG.style.width = '14px'; newSVG.style.width = '14px';
iconOrAnchor.parentNode.replaceChild(newSVG, iconOrAnchor); iconOrAnchor.parentNode?.replaceChild(newSVG, iconOrAnchor);
} }
// For the files list, use the anchor element instead of the icon because in some cases there is no icon // For the files list, use the anchor element instead of the icon because in some cases there is no icon
else { else {
if (iconOrAnchor.querySelector('img[data-material-icons-extension="icon"]')) { if (
iconOrAnchor.querySelector(
'img[data-material-icons-extension="icon"]'
)
) {
// only replace/prepend the icon once // only replace/prepend the icon once
return; return;
} }
@ -57,7 +63,7 @@ export default function sourceforge() {
const svgEl = iconOrAnchor.querySelector('svg'); const svgEl = iconOrAnchor.querySelector('svg');
if (svgEl) { if (svgEl) {
svgEl.parentNode.replaceChild(newSVG, svgEl); svgEl.parentNode?.replaceChild(newSVG, svgEl);
} else { } else {
iconOrAnchor.prepend(newSVG); iconOrAnchor.prepend(newSVG);
} }

View File

@ -48,7 +48,7 @@
</div> </div>
<div id="footer" class="centered"> <div id="footer" class="centered">
<a href="https://github.com/Claudiohbsantos/github-material-icons-extension" target="_blank" <a href="https://github.com/material-extensions/material-icons-browser-addon" target="_blank"
><img src="settings-popup.github-logo.svg" ><img src="settings-popup.github-logo.svg"
/></a> /></a>
</div> </div>

View File

@ -1,123 +0,0 @@
/* eslint-disable no-param-reassign */
import { getConfig, setConfig, clearConfig, onConfigChange } from '../../lib/userConfig';
import { getGitProviders } from '../../providers';
const resetButton = document.getElementById('reset');
const newDomainRow = () => {
const template = document.getElementById('domain-row');
if (template instanceof HTMLTemplateElement) {
const row = template.content.firstElementChild.cloneNode(true);
return row;
}
throw new Error('No row template found');
};
/**
* @param {HTMLElement} row
*/
const domainToggles = (row) => {
if (row.id === 'row-default') return;
const toggleRow = (allEnabled) => {
const checkbox = row.querySelectorAll('.extEnabled').item(0);
if (checkbox instanceof HTMLInputElement) {
checkbox.disabled = !allEnabled;
checkbox.indeterminate = !allEnabled;
}
if (allEnabled) row.classList.remove('disabled');
else row.classList.add('disabled');
};
getConfig('extEnabled', 'default').then(toggleRow);
onConfigChange('extEnabled', toggleRow, 'default');
};
/**
* @param {HTMLElement} row
* @param {string} domain
*/
const fillRow = (row, domain) => {
row.id = `row-${domain}`;
const title = row.getElementsByClassName('domain-name').item(0);
title.appendChild(document.createTextNode(domain));
if (domain === 'default') {
[...row.getElementsByClassName('default-option')].forEach((opt) => opt.remove());
}
resetButton.addEventListener('click', () => {
row.classList.add('brightDomain');
setTimeout(() => row.classList.add('animated'), 0);
setTimeout(() => row.classList.remove('brightDomain'), 0);
setTimeout(() => row.classList.remove('animated'), 800);
});
const wireConfig = (config, updateInput, updateConfig) => {
const input = row.getElementsByClassName(config).item(0);
const populateInput = () => getConfig(config, domain, false).then(updateInput(input));
input.addEventListener('change', updateConfig(config));
onConfigChange(config, updateInput(input), domain);
onConfigChange(
config,
() => getConfig(config, domain, false).then(updateInput(input)),
'default'
);
resetButton.addEventListener('click', () => clearConfig(config, domain).then(populateInput));
[...input.getElementsByClassName('default-option')].forEach((opt) => {
input.addEventListener('focus', () => {
opt.text = '(default)';
});
input.addEventListener('blur', () => {
opt.text = '';
});
});
return populateInput();
};
const updateSelect = (input) => (val) => {
input.value = val ?? 'default';
};
const updateConfigFromSelect =
(config) =>
({ target: { value } }) =>
!value || value === '(default)'
? clearConfig(config, domain)
: setConfig(config, value, domain);
const wireSelect = (config) => wireConfig(config, updateSelect, updateConfigFromSelect);
const updateCheck = (input) => (val) => {
input.checked = val ?? true;
};
const updateConfigFromCheck =
(config) =>
({ target: { checked } }) =>
setConfig(config, checked, domain);
const wireCheck = (config) => wireConfig(config, updateCheck, updateConfigFromCheck);
return Promise.all([wireSelect('iconSize'), wireSelect('iconPack'), wireCheck('extEnabled')])
.then(() => domainToggles(row))
.then(() => row);
};
function getDomains() {
return getGitProviders().then((providers) => [
'default',
...Object.values(providers)
.map((p) => p.domains.map((d) => d.host))
.flat(),
]);
}
const domainsDiv = document.getElementById('domains');
getDomains().then((domains) => {
Promise.all(domains.map((d) => fillRow(newDomainRow(), d))).then((rows) =>
rows.forEach((r) => domainsDiv.appendChild(r))
);
});

149
src/ui/options/options.ts Normal file
View File

@ -0,0 +1,149 @@
import {
UserConfig,
clearConfig,
getConfig,
onConfigChange,
setConfig,
} from '../../lib/user-config';
import { getGitProviders } from '../../providers';
const resetButton = document.getElementById(
'reset'
) as HTMLButtonElement | null;
interface DomainRowElement extends HTMLElement {
id: string;
}
const newDomainRow = (): ChildNode => {
const template = document.getElementById('domain-row');
if (template instanceof HTMLTemplateElement) {
const row = template.content.firstElementChild?.cloneNode(true);
if (!row) throw new Error('Row clone failed');
return row as ChildNode;
}
throw new Error('No row template found');
};
const domainToggles = (row: DomainRowElement): void => {
if (row.id === 'row-default') return;
const toggleRow = (allEnabled: boolean): void => {
const checkbox = row.querySelector(
'.extEnabled'
) as HTMLInputElement | null;
if (checkbox) {
checkbox.disabled = !allEnabled;
checkbox.indeterminate = !allEnabled;
}
if (allEnabled) row.classList.remove('disabled');
else row.classList.add('disabled');
};
getConfig('extEnabled', 'default').then(toggleRow);
onConfigChange('extEnabled', toggleRow, 'default');
};
const fillRow = (
rowElement: ChildNode,
domain: string
): Promise<DomainRowElement> => {
const row = rowElement as DomainRowElement;
row.id = `row-${domain}`;
const title = row.querySelector('.domain-name');
if (title) title.appendChild(document.createTextNode(domain));
if (domain === 'default') {
row.querySelectorAll('.default-option').forEach((opt) => opt.remove());
}
resetButton?.addEventListener('click', () => {
row.classList.add('brightDomain');
setTimeout(() => row.classList.add('animated'), 0);
setTimeout(() => row.classList.remove('brightDomain'), 0);
setTimeout(() => row.classList.remove('animated'), 800);
});
const wireConfig = <T>(
configName: keyof UserConfig,
updateInput: (input: HTMLElement) => (val: T) => void,
updateConfig: (configName: keyof UserConfig) => (event: Event) => void
): Promise<void> => {
const input = row.querySelector(`.${configName}`) as HTMLElement;
const populateInput = (): Promise<void> =>
getConfig(configName, domain, false).then(updateInput(input));
input.addEventListener('change', updateConfig(configName));
onConfigChange(configName, updateInput(input), domain);
onConfigChange(
configName,
() => getConfig(configName, domain, false).then(updateInput(input)),
'default'
);
resetButton?.addEventListener('click', () =>
clearConfig(configName, domain).then(populateInput)
);
input.querySelectorAll('.default-option').forEach((opt) => {
input.addEventListener('focus', () => {
(opt as HTMLOptionElement).text = '(default)';
});
input.addEventListener('blur', () => {
(opt as HTMLOptionElement).text = '';
});
});
return populateInput();
};
const updateSelect = (input: HTMLElement) => (val?: string) => {
(input as HTMLSelectElement).value = val ?? 'default';
};
const updateConfigFromSelect =
(configName: keyof UserConfig) =>
({ target }: Event) => {
const value = (target as HTMLSelectElement).value;
return !value || value === '(default)'
? clearConfig(configName, domain)
: setConfig(configName, value, domain);
};
const wireSelect = (configName: keyof UserConfig) =>
wireConfig(configName, updateSelect, updateConfigFromSelect);
const updateCheck = (input: HTMLElement) => (val?: boolean) => {
(input as HTMLInputElement).checked = val ?? true;
};
const updateConfigFromCheck =
(configName: keyof UserConfig) =>
({ target }: Event) => {
const checked = (target as HTMLInputElement).checked;
return setConfig(configName, checked, domain);
};
const wireCheck = (configName: keyof UserConfig) =>
wireConfig(configName, updateCheck, updateConfigFromCheck);
return Promise.all([
wireSelect('iconSize'),
wireSelect('iconPack'),
wireCheck('extEnabled'),
])
.then(() => domainToggles(row))
.then(() => row);
};
function getDomains(): Promise<string[]> {
return getGitProviders().then((providers) => [
'default',
...Object.values(providers).flatMap((p) => p.domains.map((d) => d.host)),
]);
}
const domainsDiv = document.getElementById('domains') as HTMLDivElement;
getDomains().then((domains) => {
Promise.all(domains.map((d) => fillRow(newDomainRow(), d))).then((rows) =>
rows.forEach((r) => domainsDiv.appendChild(r))
);
});

View File

@ -3,7 +3,7 @@ body {
margin: 0; margin: 0;
color: #1a202c; color: #1a202c;
font-size: 16px; font-size: 16px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
} }
#content { #content {

View File

@ -18,9 +18,10 @@
</div> </div>
<div id="request"> <div id="request">
We need access to the website to show the icons. If you click the button bellow the We need access to the website to show the icons. If you click the
browser will ask you for permision in another popup, this one will close. If you allow the button bellow the browser will ask you for permision in another popup,
permission you will have to come back to finish the setup. this one will close. If you allow the permission you will have to come
back to finish the setup.
<button type="button" id="request-access" class="btn"> <button type="button" id="request-access" class="btn">
<span>Allow access to website</span> <span>Allow access to website</span>
@ -28,11 +29,13 @@
</div> </div>
<div id="not-supported"> <div id="not-supported">
<span id="unsupported-domain"></span> is not supported by this extension <span id="unsupported-domain"></span> is not supported by this
extension
</div> </div>
<div id="default-disabled-note"> <div id="default-disabled-note">
All icon replacements are disabled for all domains. To change this setting, go to the All icon replacements are disabled for all domains. To change this
setting, go to the
<a id="options-link">options page</a> <a id="options-link">options page</a>
</div> </div>
@ -62,7 +65,10 @@
focusable="false" focusable="false"
aria-hidden="true" aria-hidden="true"
> >
<path fill="currentColor" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path> <path
fill="currentColor"
d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"
></path>
</svg> </svg>
</div> </div>
</div> </div>
@ -86,7 +92,10 @@
focusable="false" focusable="false"
aria-hidden="true" aria-hidden="true"
> >
<path fill="currentColor" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path> <path
fill="currentColor"
d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"
></path>
</svg> </svg>
</div> </div>
</div> </div>
@ -105,7 +114,10 @@
focusable="false" focusable="false"
aria-hidden="true" aria-hidden="true"
> >
<path fill="currentColor" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path> <path
fill="currentColor"
d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"
></path>
</svg> </svg>
</div> </div>
</div> </div>
@ -118,7 +130,7 @@
<div id="footer"> <div id="footer">
<a <a
href="https://github.com/Claudiohbsantos/github-material-icons-extension" href="https://github.com/material-extensions/material-icons-browser-addon"
target="_blank" target="_blank"
><img src="settings-popup.github-logo.svg" ><img src="settings-popup.github-logo.svg"
/></a> /></a>

View File

@ -1,218 +0,0 @@
import Browser from 'webextension-polyfill';
import { addCustomProvider } from '../../lib/custom-providers';
import { getConfig, setConfig } from '../../lib/userConfig';
import { addGitProvider, getGitProvider, providerConfig } from '../../providers';
const HOST_IS_NEW = 1;
const HOST_NO_MATCH = 2;
const isPageSupported = (domain) => getGitProvider(domain);
function getCurrentTab() {
const queryOptions = { active: true, currentWindow: true };
return Browser.tabs.query(queryOptions).then(([tab]) => tab);
}
function registerControls(domain) {
getConfig('iconSize', domain).then((size) => {
document.getElementById('icon-size').value = size;
});
const updateIconSize = (event) => setConfig('iconSize', event.target.value, domain);
document?.getElementById('icon-size')?.addEventListener('change', updateIconSize);
getConfig('iconPack', domain).then((pack) => {
document.getElementById('icon-pack').value = pack;
});
const updateIconPack = (event) => setConfig('iconPack', event.target.value, domain);
document?.getElementById('icon-pack')?.addEventListener('change', updateIconPack);
getConfig('extEnabled', domain).then((enabled) => {
document.getElementById('enabled').checked = enabled;
});
const updateExtEnabled = (event) => setConfig('extEnabled', event.target.checked, domain);
document?.getElementById('enabled')?.addEventListener('change', updateExtEnabled);
document
.getElementById('options-btn')
?.addEventListener('click', () => Browser.runtime.openOptionsPage());
}
function setDomain(domain) {
document.getElementById('domain-name').innerText = domain;
}
function displayDomainSettings() {
document.getElementById('domain-settings').style.display = 'block';
}
function displayPageNotSupported(domain) {
document.getElementById('unsupported-domain').innerText = domain;
document.getElementById('not-supported').style.display = 'block';
}
function askDomainAccess(tab) {
document.getElementById('request').style.display = 'block';
const clicked = () => {
requestAccess(tab);
// window.close();
};
document.getElementById('request-access').addEventListener('click', clicked);
}
function displayCustomDomain(tab, domain, suggestedProvider) {
document.getElementById('enable-wrapper').style.display = 'none';
document.getElementById('icon-size-wrapper').style.display = 'none';
document.getElementById('icon-pack-wrapper').style.display = 'none';
const btn = document.getElementById('add-provider');
const providerEl = document.getElementById('provider-wrapper');
btn.style.display = 'block';
providerEl.style.display = 'block';
const select = providerEl.querySelector('#provider');
for (const provider of Object.values(providerConfig)) {
if (!provider.isCustom && provider.canSelfHost) {
const selected = provider.name === suggestedProvider;
const opt = new Option(provider.name, provider.name, selected, selected);
select.append(opt);
}
}
const addProvider = () => {
addCustomProvider(domain, select.value).then(() => {
addGitProvider(domain, select.value);
const cmd = {
cmd: 'init',
};
Browser.tabs.sendMessage(tab.id, cmd);
// reload the popup to show the settings.
window.location.reload();
});
};
btn.addEventListener('click', addProvider);
}
function displayAllDisabledNote() {
getConfig('extEnabled', 'default').then((enabled) => {
if (enabled) return;
document.getElementById('default-disabled-note').style.display = 'block';
document.getElementById('domain-settings').style.display = 'none';
document
.getElementById('options-link')
?.addEventListener('click', () => Browser.runtime.openOptionsPage());
});
}
function guessProvider(tab) {
const possibilities = {};
for (const provider of Object.values(providerConfig)) {
if (!provider.isCustom && provider.canSelfHost && provider.selectors.detect) {
possibilities[provider.name] = provider.selectors.detect;
}
}
const cmd = {
cmd: 'guessProvider',
args: [possibilities],
};
return Browser.tabs.sendMessage(tab.id, cmd).then((match) => {
if (match === null) {
return HOST_NO_MATCH;
}
return match;
});
}
function checkAccess(tab) {
const { host } = new URL(tab.url);
const perm = {
permissions: ['activeTab'],
origins: [`*://${host}/*`],
};
return Browser.permissions.contains(perm).then((r) => {
if (r) {
return tab;
}
return HOST_IS_NEW;
});
}
function requestAccess(tab) {
const { host } = new URL(tab.url);
return Browser.runtime.sendMessage({
event: 'request-access',
data: {
tabId: tab.id,
url: tab.url,
host,
},
});
}
function doGuessProvider(tab, domain) {
return guessProvider(tab).then((match) => {
if (match !== HOST_NO_MATCH) {
registerControls(domain);
displayDomainSettings();
return displayCustomDomain(tab, domain, match);
}
return displayPageNotSupported(domain);
});
}
function isFirefox() {
return typeof browser !== 'undefined' && typeof chrome !== 'undefined';
}
function init(tab) {
const domain = new URL(tab.url).host;
setDomain(domain);
isPageSupported(domain).then((supported) => {
if (!supported) {
// we are in some internal browser page, not supported.
if (!tab.url.startsWith('http')) {
return displayPageNotSupported(domain);
}
// overwrite for firefox browser, currently does not support
// asking for permissions from background, so it will run
// on all pages.
if (isFirefox()) {
return doGuessProvider(tab, domain);
}
return checkAccess(tab).then((access) => {
if (access === HOST_IS_NEW) {
return askDomainAccess(tab);
}
return doGuessProvider(tab, domain);
});
}
registerControls(domain);
displayDomainSettings();
displayAllDisabledNote();
});
}
getCurrentTab().then(init);

View File

@ -0,0 +1,250 @@
import Browser from 'webextension-polyfill';
import { addCustomProvider } from '../../lib/custom-providers';
import { getConfig, setConfig } from '../../lib/user-config';
import {
addGitProvider,
getGitProvider,
providerConfig,
} from '../../providers';
const HOST_IS_NEW = 1;
const HOST_NO_MATCH = 2;
const isPageSupported = (domain: string) => getGitProvider(domain);
function getCurrentTab() {
const queryOptions = { active: true, currentWindow: true };
return Browser.tabs.query(queryOptions).then(([tab]) => tab);
}
function registerControls(domain: string) {
getConfig('iconSize', domain).then((size) => {
getElementByIdOrThrow<HTMLInputElement>('icon-size').value = size;
});
const updateIconSize = (event: Event) =>
setConfig('iconSize', (event.target as HTMLInputElement).value, domain);
document
?.getElementById('icon-size')
?.addEventListener('change', updateIconSize);
getConfig('iconPack', domain).then((pack) => {
getElementByIdOrThrow<HTMLInputElement>('icon-pack').value = pack;
});
const updateIconPack = (event: Event) =>
setConfig('iconPack', (event.target as HTMLInputElement).value, domain);
document
?.getElementById('icon-pack')
?.addEventListener('change', updateIconPack);
getConfig('extEnabled', domain).then((enabled) => {
getElementByIdOrThrow<HTMLInputElement>('enabled').checked = enabled;
});
const updateExtEnabled = (event: Event) =>
setConfig('extEnabled', (event.target as HTMLInputElement).checked, domain);
document
?.getElementById('enabled')
?.addEventListener('change', updateExtEnabled);
document
.getElementById('options-btn')
?.addEventListener('click', () => Browser.runtime.openOptionsPage());
}
function setDomain(domain: string) {
getElementByIdOrThrow('domain-name').innerText = domain;
}
function displayDomainSettings() {
getElementByIdOrThrow('domain-settings').style.display = 'block';
}
function displayPageNotSupported(domain: string) {
getElementByIdOrThrow('unsupported-domain').innerText = domain;
getElementByIdOrThrow('not-supported').style.display = 'block';
}
function askDomainAccess(tab: Browser.Tabs.Tab) {
getElementByIdOrThrow('request').style.display = 'block';
const clicked = () => {
requestAccess(tab);
};
getElementByIdOrThrow('request-access').addEventListener('click', clicked);
}
function displayCustomDomain(
tab: Browser.Tabs.Tab,
domain: string,
suggestedProvider: string
) {
getElementByIdOrThrow('enable-wrapper').style.display = 'none';
getElementByIdOrThrow('icon-size-wrapper').style.display = 'none';
getElementByIdOrThrow('icon-pack-wrapper').style.display = 'none';
const btn = getElementByIdOrThrow('add-provider');
const providerEl = getElementByIdOrThrow('provider-wrapper');
btn.style.display = 'block';
providerEl.style.display = 'block';
const select = providerEl.querySelector(
'#provider'
) as HTMLInputElement | null;
for (const provider of Object.values(providerConfig)) {
if (!provider.isCustom && provider.canSelfHost) {
const selected = provider.name === suggestedProvider;
const opt = new Option(provider.name, provider.name, selected, selected);
select?.append(opt);
}
}
const addProvider = () => {
if (!select) return;
addCustomProvider(domain, select.value).then(() => {
addGitProvider(domain, select.value);
const cmd = {
cmd: 'init',
};
Browser.tabs.sendMessage(tab.id ?? 0, cmd);
// reload the popup to show the settings.
window.location.reload();
});
};
btn.addEventListener('click', addProvider);
}
function displayAllDisabledNote() {
getConfig('extEnabled', 'default').then((enabled) => {
if (enabled) return;
getElementByIdOrThrow('default-disabled-note').style.display = 'block';
getElementByIdOrThrow('domain-settings').style.display = 'none';
document
.getElementById('options-link')
?.addEventListener('click', () => Browser.runtime.openOptionsPage());
});
}
function guessProvider(tab: Browser.Tabs.Tab) {
const possibilities: Record<string, string> = {};
for (const provider of Object.values(providerConfig)) {
if (
!provider.isCustom &&
provider.canSelfHost &&
provider.selectors.detect
) {
possibilities[provider.name] = provider.selectors.detect;
}
}
const cmd = {
cmd: 'guessProvider',
args: [possibilities],
};
return Browser.tabs.sendMessage(tab.id ?? 0, cmd).then((match) => {
if (match === null) {
return HOST_NO_MATCH;
}
return match;
});
}
function getElementByIdOrThrow<T = HTMLElement>(id: string): NonNullable<T> {
const el = document.getElementById(id) as T | null;
if (!el) {
throw new Error(`Element with id ${id} not found`);
}
return el;
}
function checkAccess(tab: Browser.Tabs.Tab) {
const { host } = new URL(tab.url ?? '');
const perm = {
permissions: ['activeTab'],
origins: [`*://${host}/*`],
};
return Browser.permissions.contains(perm).then((r) => {
if (r) {
return tab;
}
return HOST_IS_NEW;
});
}
function requestAccess(tab: Browser.Tabs.Tab) {
const { host } = new URL(tab.url ?? '');
return Browser.runtime.sendMessage({
event: 'request-access',
data: {
tabId: tab.id,
url: tab.url,
host,
},
});
}
function doGuessProvider(tab: Browser.Tabs.Tab, domain: string) {
return guessProvider(tab).then((match) => {
if (match !== HOST_NO_MATCH) {
registerControls(domain);
displayDomainSettings();
return displayCustomDomain(tab, domain, match);
}
return displayPageNotSupported(domain);
});
}
function isFirefox() {
return navigator.userAgent.toLowerCase().includes('firefox');
}
function init(tab: Browser.Tabs.Tab) {
const domain = new URL(tab.url ?? '').host;
setDomain(domain);
isPageSupported(domain).then((supported) => {
if (!supported) {
// we are in some internal browser page, not supported.
if (tab.url && !tab.url.startsWith('http')) {
return displayPageNotSupported(domain);
}
// overwrite for firefox browser, currently does not support
// asking for permissions from background, so it will run
// on all pages.
if (isFirefox()) {
return doGuessProvider(tab, domain);
}
return checkAccess(tab).then((access) => {
if (access === HOST_IS_NEW) {
return askDomainAccess(tab);
}
return doGuessProvider(tab, domain);
});
}
registerControls(domain);
displayDomainSettings();
displayAllDisabledNote();
});
}
getCurrentTab().then(init);

View File

@ -1,10 +0,0 @@
const { extendDefaultPlugins } = require('svgo');
module.exports = {
plugins: extendDefaultPlugins([
{
name: 'removeViewBox',
active: false,
},
]),
};

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"lib": ["es2022", "dom"]
},
"include": ["./src/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -1 +0,0 @@
8b32ebe2b03442def2e611cba34ad0e9d8866fd3

View File

@ -1 +0,0 @@
5.3.0