Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca7e75a81 | ||
|
|
7f30808e30 | ||
|
|
6f56b8f1f6 | ||
|
|
b1a94838ac | ||
|
|
ecccc7b963 |
@@ -30,6 +30,11 @@
|
||||
- any-glob-to-any-file:
|
||||
- 'icons/*.svg'
|
||||
|
||||
🔬 Lab icon:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'lab/*.svg'
|
||||
|
||||
# For changes in the lucide package
|
||||
🧳 lucide package:
|
||||
- changed-files:
|
||||
|
||||
@@ -3,6 +3,7 @@ name: Lucide Lab checks
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- lab/**
|
||||
- packages/lab/**
|
||||
- packages/shared/**
|
||||
- tools/build-icons/**
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
name: Pull request missing issue
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
paths:
|
||||
- 'icons/*.svg'
|
||||
|
||||
permissions: {}
|
||||
|
||||
# Two events landing close together (a push right after opening) would
|
||||
# otherwise both find no existing comment and post one each.
|
||||
concurrency:
|
||||
group: missing-issue-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pull-request-missing-issue:
|
||||
name: Pull Request Missing Issue
|
||||
if: github.repository == 'lucide-icons/lucide' && github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # Required to post and update the comment on the pull request
|
||||
|
||||
steps:
|
||||
# We checkout the main branch of main repository for security reasons.
|
||||
# The script never runs code from the pull request, it only reads the
|
||||
# pull request's icons through the GitHub API.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: lucide-icons/lucide
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
cache: 'pnpm'
|
||||
node-version-file: 'package.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate comment markup
|
||||
id: markup
|
||||
run: node ./scripts/generateMissingIssueComment.mts
|
||||
env:
|
||||
PULL_REQUEST_NUMBER: ${{ github.event.number }}
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Find comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: pr-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: lucide-missing-issue-comment
|
||||
|
||||
- name: Post missing issue comment
|
||||
if: steps.markup.outputs.action == 'nag'
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
comment-id: ${{ steps.pr-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.number }}
|
||||
body-path: missing-issue-comment.md
|
||||
edit-mode: replace
|
||||
|
||||
# Linking an issue from the pull request's Development sidebar fires no
|
||||
# event, so the comment resolves on the next edit or push instead.
|
||||
- name: Resolve missing issue comment
|
||||
if: steps.markup.outputs.action == 'resolve' && steps.pr-comment.outputs.comment-id != ''
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
comment-id: ${{ steps.pr-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.number }}
|
||||
body: |
|
||||
<!-- lucide-missing-issue-comment -->
|
||||
Thanks for linking an issue for this icon design! ✅
|
||||
edit-mode: replace
|
||||
@@ -0,0 +1,146 @@
|
||||
name: Release @lucide/lab
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- lab/**/*.svg
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: Release type
|
||||
required: true
|
||||
default: 'minor'
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
if: github.repository == 'lucide-icons/lucide'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Required to create the release and tag
|
||||
outputs:
|
||||
VERSION: ${{ steps.new-version.outputs.NEW_VERSION }}
|
||||
SHOULD_RELEASE: ${{ steps.release-notes.outputs.SHOULD_RELEASE }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0 # Required to diff the lab icons against the previous tag
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
cache: 'pnpm'
|
||||
node-version-file: 'package.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Get latest tag
|
||||
id: latest-tag
|
||||
run: |
|
||||
LATEST_TAG=$(git tag --list '@lucide/lab@*' --sort=-v:refname | head -n 1)
|
||||
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
echo "::error::No @lucide/lab@* tag found to release from."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Latest tag: $LATEST_TAG"
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create new version
|
||||
id: new-version
|
||||
run: |
|
||||
NEW_VERSION=$(pnpm semver "${LATEST_TAG#@lucide/lab@}" -i "$RELEASE_TYPE")
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
LATEST_TAG: ${{ steps.latest-tag.outputs.LATEST_TAG }}
|
||||
RELEASE_TYPE: ${{ inputs.release-type || 'minor' }}
|
||||
|
||||
- name: Generate release notes
|
||||
id: release-notes
|
||||
run: |
|
||||
NEW_TAG="@lucide/lab@$NEW_VERSION"
|
||||
|
||||
ADDED=$(git diff --name-only --diff-filter=A "$LATEST_TAG" HEAD -- 'lab/*.svg' \
|
||||
| xargs -r -n1 basename | sed 's/\.svg$//' | sort)
|
||||
REMOVED=$(git diff --name-only --diff-filter=D "$LATEST_TAG" HEAD -- 'lab/*.svg' \
|
||||
| xargs -r -n1 basename | sed 's/\.svg$//' | sort)
|
||||
|
||||
if [ -z "$ADDED" ] && [ "$EVENT_NAME" != 'workflow_dispatch' ]; then
|
||||
echo "::notice::No icons were added to ./lab since $LATEST_TAG, skipping release."
|
||||
echo "SHOULD_RELEASE=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
if [ -n "$ADDED" ]; then
|
||||
printf '## New icons\n\n'
|
||||
printf '%s\n' "$ADDED" | sed 's|.*|- `&`|'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
if [ -n "$REMOVED" ]; then
|
||||
printf '## Removed icons\n\nThese graduated to the core icon set.\n\n'
|
||||
printf '%s\n' "$REMOVED" | sed 's|.*|- `&`|'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
printf '**Full changelog**: https://github.com/lucide-icons/lucide/compare/%s...%s\n' "$LATEST_TAG" "$NEW_TAG"
|
||||
} > lab-release-notes.md
|
||||
|
||||
cat lab-release-notes.md
|
||||
echo "SHOULD_RELEASE=true" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
LATEST_TAG: ${{ steps.latest-tag.outputs.LATEST_TAG }}
|
||||
NEW_VERSION: ${{ steps.new-version.outputs.NEW_VERSION }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
|
||||
- name: Create Release
|
||||
if: steps.release-notes.outputs.SHOULD_RELEASE == 'true'
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: '@lucide/lab@${{ steps.new-version.outputs.NEW_VERSION }}'
|
||||
name: '@lucide/lab@${{ steps.new-version.outputs.NEW_VERSION }}'
|
||||
body_path: lab-release-notes.md
|
||||
make_latest: 'false' # Never displace the core icon release as "Latest"
|
||||
|
||||
publish:
|
||||
if: github.repository == 'lucide-icons/lucide' && needs.create-release.outputs.SHOULD_RELEASE == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-release
|
||||
permissions:
|
||||
id-token: write # Required for OIDC (npm provenance)
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
cache: 'pnpm'
|
||||
node-version-file: 'package.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set new version
|
||||
run: pnpm version ${{ needs.create-release.outputs.VERSION }} --filter @lucide/lab --fail-if-no-match --no-git-tag-version
|
||||
|
||||
- name: Build
|
||||
run: pnpm --filter @lucide/lab build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm --filter @lucide/lab publish --access public --no-git-checks --ignore-scripts --tag latest
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
@@ -9,6 +9,6 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z" />
|
||||
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" />
|
||||
<path d="M11 20a10 10 0 0010-10 25.9 25.9 0 00-1.04-7.281 1 1 0 00-1.755-.325C15.833 5.5 13 5.5 9.8 6.1A7 7 0 0011 20" />
|
||||
<path d="M2 21a5 5 0 012.911-4.544C7.613 15.212 8.351 15.24 11 13" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 368 B After Width: | Height: | Size: 404 B |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"colebemis",
|
||||
"csandman",
|
||||
"ericfennis",
|
||||
"karsa-mistmere"
|
||||
],
|
||||
"use-cases": [],
|
||||
"tags": [
|
||||
"garbage",
|
||||
"delete",
|
||||
"remove",
|
||||
"bin"
|
||||
],
|
||||
"categories": [
|
||||
"files",
|
||||
"mail"
|
||||
]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 389 B |
@@ -26,5 +26,12 @@
|
||||
"categories": [
|
||||
"files",
|
||||
"mail"
|
||||
],
|
||||
"aliases": [
|
||||
{
|
||||
"name": "trash-2",
|
||||
"deprecated": true,
|
||||
"deprecationReason": "alias.duplicate"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
|
||||
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 389 B |
@@ -9,13 +9,13 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="4" height="4" x="2" y="3" />
|
||||
<path d="M6 7v2c0 .6.1 1.4.2 2L8 20.8v.2c0 .6-.4 1-1 1H3c-.6 0-1-.4-1-1V7" />
|
||||
<path d="M14 2v7l-2 5v8" />
|
||||
<rect width="4" height="6" x="10" y="3" />
|
||||
<path d="M18 6h4" />
|
||||
<path d="M14 11h-2a2 2 0 01-1.73-3 2 2 0 010-2A2 2 0 0112 3h2" />
|
||||
<path d="M14 2v9.35a4 4 0 01-.205 1.266l-.59 1.768A4 4 0 0013 15.65V22" />
|
||||
<path d="M18 10h4" />
|
||||
<path d="M18 14h4" />
|
||||
<path d="M18 18h4" />
|
||||
<path d="M18 2h2a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M18 2h2a2 2 0 012 2v16a2 2 0 01-2 2h-2" />
|
||||
<path d="M18 6h4" />
|
||||
<path d="M2 3a1 1 0 011-1h2a1 1 0 011 1v6c0 .6.1 1.4.2 2L8 20.8v.2a1 1 0 01-1 1H3a1 1 0 01-1-1z" />
|
||||
<path d="M2 6h4" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 626 B |
@@ -27,7 +27,6 @@
|
||||
"generate:contributors": "node ./scripts/updateContributors.mts icons/*.svg",
|
||||
"generate:nextJSAliases": "node ./scripts/generateNextJSAliases.mts",
|
||||
"suggest:metadata": "node ./scripts/suggestMetaData.mts",
|
||||
"check:missing-issue": "node ./scripts/generateMissingIssueComment.mts",
|
||||
"suggest:metadata:watch": "node --env-file .env --watch ./scripts/suggestMetaData.mts",
|
||||
"postinstall": "husky install",
|
||||
"lint:es": "eslint .",
|
||||
|
||||
@@ -36,8 +36,7 @@
|
||||
"build:icons": "build-icons --input=../../lab --output=./src --templateSrc=./scripts/exportTemplate.mts --iconFileExtension=.ts --exportModuleNameCasing=camel --renderUniqueKey --exportFileName=index.ts",
|
||||
"build:lib": "node ./scripts/buildLib.mts",
|
||||
"build:bundle": "rollup -c rollup.config.mjs",
|
||||
"clean": "rm -f src/icons/*.ts && rm -rf dist && rm -rf icons && rm -f icon-nodes.json",
|
||||
"version": "pnpm version --git-tag-version=false"
|
||||
"clean": "rm -f src/icons/*.ts && rm -rf dist && rm -rf icons && rm -f icon-nodes.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/build-icons": "workspace:*",
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { minifySvg } from '../tools/build-helpers/helpers.ts';
|
||||
|
||||
// Resolve repo paths relative to this script so they work no matter which
|
||||
// directory the script is invoked from (the workflow runs it from the repo root).
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.join(scriptDir, '..');
|
||||
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
const pullRequestNumber = Number(process.env.PULL_REQUEST_NUMBER);
|
||||
const commitSha = process.env.COMMIT_SHA ?? 'HEAD';
|
||||
const useFileSystem = process.env.USE_FILE_SYSTEM === 'true';
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
|
||||
const owner = 'lucide-icons';
|
||||
const repo = 'lucide';
|
||||
|
||||
// Marker that lets `peter-evans/find-comment` recognise our own comment so we
|
||||
// update it in place instead of stacking a new nag on every PR edit. It must
|
||||
// stay distinct from the `### Added or changed icons` marker used by the icon
|
||||
// preview bot, or the two workflows would overwrite each other.
|
||||
const COMMENT_MARKER = '<!-- lucide-missing-issue-comment -->';
|
||||
const COMMENT_FILE = 'missing-issue-comment.md';
|
||||
const ICON_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
|
||||
const GH_ICON_BASE_URL = 'https://lucide.dev/api/gh-icon';
|
||||
const STUDIO_BASE_URL = 'https://studio.lucide.dev/edit';
|
||||
const ISSUE_TEMPLATE = '01_icon_request.yml';
|
||||
|
||||
type Action = 'nag' | 'resolve' | 'none';
|
||||
|
||||
// Only these two fields of a pull request file entry are used. `octokit.paginate`
|
||||
// is avoided in favour of a plain page loop because
|
||||
// `@octokit/plugin-paginate-rest` and `@octokit/plugin-rest-endpoint-methods`
|
||||
// resolve to different `@octokit/types` versions (9.3.2 and 10.0.0), which makes
|
||||
// `paginate`'s overloads unresolvable.
|
||||
type PullRequestFile = { filename: string; status: string };
|
||||
|
||||
if (!Number.isInteger(pullRequestNumber)) {
|
||||
console.error('PULL_REQUEST_NUMBER env variable is not a valid number');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Reports back to the workflow which of the three comment steps should run. */
|
||||
async function setAction(action: Action) {
|
||||
console.log(`action=${action}`);
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
await fs.appendFile(process.env.GITHUB_OUTPUT, `action=${action}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file as it exists on the pull request's head commit, returning null
|
||||
* when it is not there. A fork's head commit is reachable from this repository
|
||||
* through `refs/pull/<number>/head`, so the fork is never checked out.
|
||||
*
|
||||
* Everything this returns is contributor-controlled, so callers must treat it
|
||||
* strictly as data.
|
||||
*/
|
||||
async function readPullRequestFile(filename: string) {
|
||||
if (useFileSystem) {
|
||||
try {
|
||||
return await fs.readFile(path.join(repoRoot, filename), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// `repos.getContent` returns base64 in a JSON envelope. Reading the raw
|
||||
// url instead would hand back an ArrayBuffer for `image/svg+xml`.
|
||||
const { data } = await octokit.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: filename,
|
||||
ref: commitSha,
|
||||
});
|
||||
|
||||
if (Array.isArray(data) || data.type !== 'file' || data.content == null) return null;
|
||||
|
||||
return Buffer.from(data.content, 'base64').toString('utf-8');
|
||||
} catch (error) {
|
||||
// A 404 means either no metadata file yet, or the fork force pushed past
|
||||
// the sha in the event payload.
|
||||
if ((error as { status?: number }).status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the `### Icon use case` section out of a pull request description, as
|
||||
* laid out by `.github/pull_request_template.md`. HTML comments are stripped
|
||||
* *after* matching so an untouched template — where the section contains
|
||||
* nothing but the instructional comment — yields an empty string rather than
|
||||
* pasting our own instructions into the new issue.
|
||||
*/
|
||||
function extractUseCasesFromDescription(pullRequestBody: string) {
|
||||
const match = pullRequestBody.match(
|
||||
/^#{2,4}[ \t]*Icon use cases?[ \t]*(?:<!--[\s\S]*?-->)?[ \t]*$([\s\S]*?)(?=^#{1,4}[ \t]|$(?![\s\S]))/im,
|
||||
);
|
||||
|
||||
if (!match) return '';
|
||||
|
||||
return match[1]
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Prefers the icon's own metadata, falling back to the PR description. */
|
||||
function getUseCases(metadata: unknown, pullRequestBody: string) {
|
||||
const useCases =
|
||||
metadata != null && typeof metadata === 'object'
|
||||
? (metadata as Record<string, unknown>)['use-cases']
|
||||
: undefined;
|
||||
|
||||
if (Array.isArray(useCases)) {
|
||||
const entries = useCases.filter(
|
||||
(useCase): useCase is string => typeof useCase === 'string' && useCase.trim() !== '',
|
||||
);
|
||||
|
||||
if (entries.length > 0) {
|
||||
return entries.map((useCase) => `- ${useCase.trim()}`).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return extractUseCasesFromDescription(pullRequestBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a link to the icon request form with every field pre-filled. Linking to
|
||||
* the form (rather than a free-form `?body=`) means GitHub applies the template's
|
||||
* `🙌 icon request` label and renders the `### Icon name` heading that
|
||||
* `close-issue-with-banned-phrases.yml` relies on.
|
||||
*/
|
||||
function buildIssueUrl(name: string, svgContent: string, useCases: string) {
|
||||
// The gh-icon API and Lucide Studio both want the bare icon children, so drop
|
||||
// the `<svg>` wrapper. One base64 encoding then serves the preview image and
|
||||
// the studio link, matching `docs/.vitepress/theme/components/icons/IconPreview.vue`.
|
||||
const innerSvg = minifySvg(svgContent).replace(/<svg[^>]*>|<\/svg>/g, '');
|
||||
const base64 = Buffer.from(innerSvg).toString('base64');
|
||||
|
||||
const studioUrl = `${STUDIO_BASE_URL}?value=${encodeURIComponent(base64)}&name=${name}&utm_source=github.com&utm_medium=pr-comment`;
|
||||
const designIdeas = `<a title="Open lucide studio" target="_blank" href="${studioUrl}"><img alt="${name}" width="200px" src="${GH_ICON_BASE_URL}/${base64}.svg"/><br/>Open lucide studio</a>`;
|
||||
|
||||
// `URLSearchParams` is deliberate: it escapes the literal `+` and `(` `)`
|
||||
// characters that appear in base64 and in use cases, where `encodeURI` would
|
||||
// leave `+` to be decoded back into a space and corrupt the payload, and a
|
||||
// stray `)` would end the markdown link early.
|
||||
const params = new URLSearchParams({
|
||||
template: ISSUE_TEMPLATE,
|
||||
title: `${name} Icon Design`,
|
||||
name,
|
||||
'use-cases': useCases,
|
||||
'design-ideas': designIdeas,
|
||||
});
|
||||
|
||||
// Spaces are serialised as `+`, which only decodes back to a space under
|
||||
// form decoding. `%20` means a space under both that and plain url decoding,
|
||||
// so the link cannot be misread whichever way GitHub parses it.
|
||||
const query = params.toString().replaceAll('+', '%20');
|
||||
|
||||
return `https://github.com/${owner}/${repo}/issues/new?${query}`;
|
||||
}
|
||||
|
||||
const { data: pullRequest } = await octokit.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
});
|
||||
|
||||
const pullRequestBody = pullRequest.body ?? '';
|
||||
|
||||
// `closingIssuesReferences` covers both `closes #123` keywords in the description
|
||||
// and issues linked by hand through the Development sidebar. A bare `#123`
|
||||
// mention deliberately does not count as a link.
|
||||
const { repository } = await octokit.graphql<{
|
||||
repository: { pullRequest: { closingIssuesReferences: { totalCount: number } } };
|
||||
}>(
|
||||
`query ($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 1) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, repo, number: pullRequestNumber },
|
||||
);
|
||||
|
||||
if (repository.pullRequest.closingIssuesReferences.totalCount > 0) {
|
||||
console.log(`Pull request #${pullRequestNumber} references an issue.`);
|
||||
await setAction('resolve');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files: PullRequestFile[] = [];
|
||||
|
||||
// A pull request adding a family of icons can hold well over a hundred files,
|
||||
// and `listFiles` pages at a hundred at a time.
|
||||
for (let page = 1; ; page += 1) {
|
||||
const { data } = await octokit.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
|
||||
files.push(...data);
|
||||
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
|
||||
// An icon that leaves `lab/` in the same pull request is being promoted out of
|
||||
// the lab rather than designed from scratch, so it already had its discussion.
|
||||
const promotedLabIcons = new Set(
|
||||
files
|
||||
.filter((file) => file.status === 'removed' && /^lab\/[^/]+\.svg$/.test(file.filename))
|
||||
.map((file) => path.basename(file.filename, '.svg')),
|
||||
);
|
||||
|
||||
// Only brand new icons need a design issue; redesigns of existing icons already
|
||||
// have one, and renames are not new designs. Sorted so re-runs of the same pull
|
||||
// request produce an identical comment instead of reshuffling the links.
|
||||
const addedIcons = files
|
||||
.filter(
|
||||
(file) =>
|
||||
file.status === 'added' &&
|
||||
/^icons\/[^/]+\.svg$/.test(file.filename) &&
|
||||
!promotedLabIcons.has(path.basename(file.filename, '.svg')),
|
||||
)
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
|
||||
if (addedIcons.length === 0) {
|
||||
console.log('No added icons found');
|
||||
await setAction('none');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const issueLinks = (
|
||||
await Promise.all(
|
||||
addedIcons.map(async ({ filename }) => {
|
||||
const name = path.basename(filename, '.svg');
|
||||
|
||||
// The filename comes from a fork and ends up rendered in our comment, so
|
||||
// anything that is not a plain icon slug is skipped rather than trusted.
|
||||
if (!ICON_NAME_PATTERN.test(name)) {
|
||||
console.log(`Skipping "${filename}": not a valid icon name.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const svgContent = await readPullRequestFile(filename);
|
||||
|
||||
if (svgContent == null) {
|
||||
console.log(`Could not read ${filename}. Skipping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadataContent = await readPullRequestFile(`icons/${name}.json`);
|
||||
let metadata: unknown;
|
||||
|
||||
if (metadataContent != null) {
|
||||
try {
|
||||
metadata = JSON.parse(metadataContent);
|
||||
} catch {
|
||||
// A malformed metadata file is `lint:json:icons`' problem, not ours.
|
||||
console.log(`Could not parse icons/${name}.json. Falling back to the PR description.`);
|
||||
}
|
||||
}
|
||||
|
||||
const useCases = getUseCases(metadata, pullRequestBody);
|
||||
const issueUrl = buildIssueUrl(name, svgContent, useCases);
|
||||
|
||||
return `[Create an issue for the \`${name}\` icon](${issueUrl})`;
|
||||
}),
|
||||
)
|
||||
).filter((link): link is string => link !== null);
|
||||
|
||||
if (issueLinks.length === 0) {
|
||||
console.log('No valid added icons found');
|
||||
await setAction('none');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const linkList = issueLinks.map((link) => `- ${link}`).join('\n');
|
||||
|
||||
// A pull request adding a whole icon family would otherwise bury the message
|
||||
// under a wall of links, so collapse the list at the same threshold
|
||||
// `generateChangedIconsCommentMarkup.mts` uses to switch layout.
|
||||
const callToAction =
|
||||
issueLinks.length === 1
|
||||
? `Click here to create an issue for this design: ${issueLinks[0]}`
|
||||
: issueLinks.length < 8
|
||||
? `Click here to create an issue for these designs:\n${linkList}`
|
||||
: `<details>
|
||||
<summary>Click here to create an issue for each of these ${issueLinks.length} designs</summary>
|
||||
|
||||
${linkList}
|
||||
|
||||
</details>`;
|
||||
|
||||
const commentMarkup = `${COMMENT_MARKER}
|
||||
It looks like your PR for a new icon design does not reference an issue.
|
||||
|
||||
Please create an issue for the design discussion. And reference it in the PR.
|
||||
|
||||
${callToAction}
|
||||
|
||||
Thank you for your contribution!
|
||||
`;
|
||||
|
||||
if (dryRun) {
|
||||
console.log(commentMarkup);
|
||||
} else {
|
||||
await fs.writeFile(path.join(process.cwd(), COMMENT_FILE), commentMarkup);
|
||||
}
|
||||
|
||||
await setAction('nag');
|
||||