Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/scripts/generate_icon_history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Generates devicon-history.json, used by the website to sort icons by release
* date and last update. For every icon in devicon.json it records:
* - released / releasedOn: the first release (v* tag) that contained the icon
* - updated / updatedOn: the release of the icon's last real change
*
* "Real" changes ignore build commits (they only re-optimize SVGs) and bulk
* commits touching more than BULK_LIMIT icons (e.g. repo-wide SVG optimizations).
* Icons that aren't in any release yet get --next-version (the release being
* made) and today's date.
*
* Usage: node .github/scripts/generate_icon_history.js [--next-version v1.2.3]
*/
const { execFileSync } = require('child_process');
const fs = require('fs');

const BULK_LIMIT = 5;
const BUILD_COMMIT = /^(build|built|build preparation)\b/i;

const git = (...args) => execFileSync('git', args, { maxBuffer: 1 << 30 }).toString();
const lines = text => text.split('\n').filter(Boolean);

// Dates are full UTC timestamps, so releases made on the same day still sort
const utc = date => new Date(date).toISOString().replace(/\.\d+Z$/, 'Z');

const nextIndex = process.argv.indexOf('--next-version');
const next = nextIndex > -1 ? { tag: process.argv[nextIndex + 1], date: utc(Date.now()) } : null;

// Releases, oldest first (old ones are tagged v1.0 … v2.7, newer ones v2.8.0 …)
const releases = lines(git('for-each-ref', '--sort=version:refname',
'--format=%(refname:short) %(creatordate:iso-strict)', 'refs/tags/v*'))
.map(line => { const [tag, date] = line.split(' '); return { tag, date: utc(date) }; })
.filter(r => /^v\d+\.\d+(\.\d+)?$/.test(r.tag));

// Which release first contained each commit and each icon folder
const releaseOfCommit = new Map();
const firstRelease = new Map();
releases.forEach((release, i) => {
const previous = releases.slice(0, i).map(r => r.tag);
for (const sha of lines(git('rev-list', release.tag, ...(previous.length ? ['--not', ...previous] : [])))) {
releaseOfCommit.set(sha, release);
}
for (const path of lines(git('ls-tree', '--name-only', `${release.tag}:icons`))) {
if (!firstRelease.has(path)) firstRelease.set(path, release);
}
});

// Last real change per icon folder (git log lists newest first)
const lastChange = new Map();
const log = git('log', '--format=%x00%H %s', '--name-only', '--', 'icons');
for (const entry of log.split('\0').filter(Boolean)) {
const [header, ...paths] = lines(entry);
const [sha, ...subject] = header.split(' ');
const folders = new Set(paths.map(p => p.split('/')[1]).filter(Boolean));
if (folders.size > BULK_LIMIT || BUILD_COMMIT.test(subject.join(' '))) continue;
for (const folder of folders) {
if (!lastChange.has(folder)) lastChange.set(folder, sha);
}
}

// Position in the release list; the upcoming release (not tagged yet) comes last
const releaseIndex = release => {
const i = releases.findIndex(r => r.tag === release.tag);
return i === -1 ? Infinity : i;
};
const history = {};
for (const { name } of JSON.parse(fs.readFileSync('devicon.json', 'utf8'))) {
const released = firstRelease.get(name) || next;
if (!released) continue;
let updated = lastChange.has(name) ? (releaseOfCommit.get(lastChange.get(name)) || next) : released;
// A change can't predate the first release containing the icon
if (!updated || releaseIndex(updated) < releaseIndex(released)) updated = released;
history[name] = { released: released.tag, releasedOn: released.date, updated: updated.tag, updatedOn: updated.date };
}

// One icon per line keeps the diffs readable
const body = Object.keys(history).sort()
.map(name => ` ${JSON.stringify(name)}: ${JSON.stringify(history[name])}`).join(',\n');
fs.writeFileSync('devicon-history.json', `{\n${body}\n}\n`);
console.log(`Wrote history for ${Object.keys(history).length} icons (${releases.length} releases${next ? `, next: ${next.tag}` : ''})`);
19 changes: 17 additions & 2 deletions .github/workflows/build_weekly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -366,12 +366,27 @@ jobs:
- name: Install Node dependencies
run: npm install

# devicon-history.json (used by the website to sort icons by release date
# and last update) is updated before tagging, so the release includes it
# and icons first shipped in this release get this release's version.
- name: Update icon history
env:
BUMP: ${{ steps.version-bump.outputs.bump }}
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
NEXT=$(node -p "const [ma, mi, pa] = require('./package.json').version.split('.').map(Number);
({ major: [ma + 1, 0, 0], minor: [ma, mi + 1, 0], patch: [ma, mi, pa + 1] })[process.env.BUMP].join('.')")
node .github/scripts/generate_icon_history.js --next-version "v$NEXT"
git add devicon-history.json
if ! git diff --cached --quiet; then
git commit -m "chore: update icon history for v$NEXT"
fi

- name: Bump version
id: bump
run: |
BUMP="${{ steps.version-bump.outputs.bump }}"
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
npm version $BUMP -m "chore: bump version to %s"
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ The check-bot labels icon PRs that have no `release:` label yet: major if an ico
### Manual Build / Release
Run `build_weekly.yml` via workflow_dispatch — it does exactly what the weekly run does (build if needed, release if anything changed). To build without releasing, reject the `production` approval. The `bump` input (`auto`/`patch`/`minor`/`major`) overrides the label-derived bump.

### Icon history (website sorting)
`devicon-history.json` is generated (don't edit it): per icon, the first release containing it and the release of its last real change (ignoring `build:` commits and commits touching more than 5 icons), with UTC timestamps. The release job regenerates it right before `npm version`, passing the upcoming version, and commits it as `chore: update icon history for vX.Y.Z`. The website (`docs/`) loads it next to `devicon.json` for the "Sort by" dropdown (Name / Newest first / Recently updated) and hides the dropdown if it's missing. Run locally with `node .github/scripts/generate_icon_history.js [--next-version vX.Y.Z]`.

### PR titles
Not used to find icons anywhere (bots, build, release all use changed files). Enforced by `check_icon_pr.yml` for icon PRs (any change in `icons/`):
- one icon per PR; only `icons/<name>/` and that icon's `devicon.json` entry may change — any other added/modified/deleted file or other `devicon.json` entry fails the check with a comment
Expand Down
10 changes: 10 additions & 0 deletions docs/assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,16 @@ input[type='color']:hover {
font-size: 1.2rem;
text-align: center; }

.search select {
margin-left: 1rem;
padding: .5rem 1rem;
border: 0;
border-bottom: 2px solid var(--primaryColor);
color: var(--primaryColor);
background: none;
font-size: 1.2rem;
cursor: pointer; }

::-webkit-input-placeholder {
color: var(--primaryColor); }

Expand Down
28 changes: 28 additions & 0 deletions docs/assets/js/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ devicon.controller('IconListCtrl', function($scope, $http, $compile) {

$scope.baseUrl = baseUrl

// Sorting: by name, or by release date / last update from
// devicon-history.json (only shown when the release has that file)
$scope.sortOptions = [
{ key: 'name', label: 'Sort by name' },
{ key: 'released', label: 'Newest first' },
{ key: 'updated', label: 'Recently updated' }
];
$scope.sortBy = 'name';
$scope.hasHistory = false;
$scope.sortOrder = function() {
if ($scope.sortBy === 'released') return ['-releasedOn', 'name'];
if ($scope.sortBy === 'updated') return ['-updatedOn', 'name'];
return 'name';
};
$scope.iconHistory = function(icon) {
if (!icon.released) return '';
return 'Released in ' + icon.released + ' (' + icon.releasedOn.slice(0, 10) + '), ' +
'last updated in ' + icon.updated + ' (' + icon.updatedOn.slice(0, 10) + ')';
};

// Get devicon.json
$http.get(baseUrl + 'devicon.json').success(function(data) {

Expand Down Expand Up @@ -117,6 +137,14 @@ devicon.controller('IconListCtrl', function($scope, $http, $compile) {
// Computes miscellaneous data
$scope.avgFonts = $scope.totalFonts / $scope.icons.length // Avg number of fonts and SVGs combined

// Release dates for sorting (older releases don't have this file)
$http.get(baseUrl + 'devicon-history.json').success(function(history) {
angular.forEach($scope.icons, function(icon) {
if (history[icon.name]) angular.extend(icon, history[icon.name]);
});
$scope.hasHistory = true;
});

/*------ End of "Re-format devicon.json" ------*/
});

Expand Down
5 changes: 3 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ <h5>If you have any legal concerns regarding copyrights or want to report an abu
<div class="search">
<input type="text" placeholder="Search for icons" ng-model="search">
<input type="text" class="result" value="{{ filtered.length }}" title="Number of icons in the current search" disabled>
<select ng-show="hasHistory" ng-model="sortBy" ng-options="option.key as option.label for option in sortOptions" title="Sort the icons"></select>
</div>

<ul class="computed-data">
Expand All @@ -209,8 +210,8 @@ <h5>If you have any legal concerns regarding copyrights or want to report an abu
</ul>

<ul class="cbp-ig-grid">
<li data-ng-repeat="icon in filtered = (icons | filter:search)">
<span ng-class="{'selected': icon.selected}" ng-click="selectIcon(icon)">
<li data-ng-repeat="icon in filtered = (icons | filter:search | orderBy:sortOrder())">
<span ng-class="{'selected': icon.selected}" ng-click="selectIcon(icon)" title="{{ iconHistory(icon) }}">
<i class="cbp-ig-icon devicon-{{icon.main}}"></i>
<h3 class="cbp-ig-title">{{icon.name}}</h3>
</span>
Expand Down
Loading