diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 000000000..5e9820680 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,55 @@ +name: Pull request comment + +on: + pull_request_target: + paths: + - 'icons/*.svg' + +permissions: + pull-requests: write + contents: write + +jobs: + Explore-GitHub-Actions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + ref: refs/pull/${{ github.event.pull_request.number }}/merge + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v35 + with: + files: icons/*.svg + - name: Generate comment + id: generate-comment + run: | + delimiter="$(openssl rand -hex 8)" + echo "body<<$delimiter" >> $GITHUB_OUTPUT + for file in ${{ steps.changed-files.outputs.all_changed_files }}; do + cat "$file" | # get file content + tr '\n' ' ' | # remove line breaks + sed -e 's/]*>//g' | # remove attributes from svg element + base64 -w 0 | # encode svg + sed "s|.*|\"$file\" |" + done | tr '\n' ' ' >> $GITHUB_OUTPUT + echo >> $GITHUB_OUTPUT + echo "$delimiter" >> $GITHUB_OUTPUT + - name: Find Comment + uses: peter-evans/find-comment@v2 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: Added or changed icons + - name: Create or update comment + uses: peter-evans/create-or-update-comment@v2 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: | + Added or changed icons + + ${{ steps.generate-comment.outputs.body }} + edit-mode: replace diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f221d8c4c..c9430ae77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,6 +389,7 @@ importers: babel-jest: ^26.5.2 babel-loader: ^8.1.0 downloadjs: ^1.4.7 + element-to-path: ^1.2.1 eslint: ^8.22.0 eslint-config-prettier: ^8.5.0 framer-motion: ^4 @@ -409,6 +410,7 @@ importers: react-dom: 17.0.2 react-svg-loader: ^3.0.3 react-test-renderer: 17.0.2 + svg-pathdata: ^6.0.3 svgson: ^5.2.1 ts-node: ~10.9.1 tslib: ^2.4.0 @@ -422,6 +424,7 @@ importers: '@next/mdx': 11.1.4_6vwedlnwdegenaf6jioaeirvlu '@svgr/webpack': 6.3.1 downloadjs: 1.4.7 + element-to-path: 1.2.1 framer-motion: 4.1.17_sfoxds7t5ydpegc3knd667wn6m fuse.js: 6.6.2 gray-matter: 4.0.3 @@ -436,6 +439,7 @@ importers: react-color: 2.19.3_react@17.0.2 react-dom: 17.0.2_react@17.0.2 react-svg-loader: 3.0.3 + svg-pathdata: 6.0.3 svgson: 5.2.1 devDependencies: '@next/eslint-plugin-next': 12.2.5 @@ -12869,6 +12873,10 @@ packages: /electron-to-chromium/1.4.284: resolution: {integrity: sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==} + /element-to-path/1.2.1: + resolution: {integrity: sha512-JNFZS0yI3Myywn/ltFj/yTihHNzMTYk0ycHcgcjlvA/dYMUjMIGqvbezPZeXN3U1Klp/aiigr2mpmhVRfudtbg==} + dev: false + /emittery/0.7.2: resolution: {integrity: sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ==} engines: {node: '>=10'} @@ -22212,6 +22220,11 @@ packages: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} dev: false + /svg-pathdata/6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + dev: false + /svgo/1.3.2: resolution: {integrity: sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==} engines: {node: '>=4.0.0'} diff --git a/site/package.json b/site/package.json index 09fcc9089..36dd993be 100644 --- a/site/package.json +++ b/site/package.json @@ -24,6 +24,7 @@ "@next/mdx": "^11.0.0", "@svgr/webpack": "^6.3.1", "downloadjs": "^1.4.7", + "element-to-path": "^1.2.1", "framer-motion": "^4", "fuse.js": "^6.5.3", "gray-matter": "^4.0.3", @@ -38,7 +39,8 @@ "react-color": "^2.19.3", "react-dom": "17.0.2", "react-svg-loader": "^3.0.3", - "svgson": "^5.2.1" + "svgson": "^5.2.1", + "svg-pathdata": "^6.0.3" }, "devDependencies": { "@next/eslint-plugin-next": "^12.2.5", diff --git a/site/src/components/IconWrapper.tsx b/site/src/components/IconWrapper.tsx index d555a1fa7..6104ced2a 100644 --- a/site/src/components/IconWrapper.tsx +++ b/site/src/components/IconWrapper.tsx @@ -5,7 +5,7 @@ interface IconWrapperProps extends SVGProps { } export const IconWrapper = forwardRef((props, ref) => { - const defaultAttrs : SVGProps= { + const defaultAttrs: SVGProps = { xmlns: 'http://www.w3.org/2000/svg', width: '24px', height: '24px', diff --git a/site/src/components/SvgPreview/index.tsx b/site/src/components/SvgPreview/index.tsx new file mode 100644 index 000000000..dcf321a73 --- /dev/null +++ b/site/src/components/SvgPreview/index.tsx @@ -0,0 +1,225 @@ +import React from 'react'; +import { PathProps, Path } from './types'; +import { getPaths, assert } from './utils'; + +const Grid = ({ + radius, + fill, + ...props +}: { + strokeWidth: number; + radius: number; +} & PathProps<'stroke', 'strokeWidth'>) => ( + + + [ + `M${props.strokeWidth} ${i + 1}h${24 - props.strokeWidth * 2}`, + `M${i + 1} ${props.strokeWidth}v${24 - props.strokeWidth * 2}`, + ]) + .join('') + } + /> + +); + +const Shadow = ({ + radius, + paths, + ...props +}: { + radius: number; + paths: Path[]; +} & PathProps<'stroke' | 'strokeWidth' | 'strokeOpacity', 'd'>) => { + const groupedPaths = Object.entries( + paths.reduce((groups, val) => { + const key = val.c.id; + groups[key] = [...(groups[key] || []), val]; + return groups; + }, {} as Record) + ); + return ( + <> + + {groupedPaths.map(([id, paths]) => ( + + + [ + `M${prev.x} ${prev.y}h.01`, + `M${next.x} ${next.y}h.01`, + ]) + .filter((val, idx, arr) => arr.indexOf(val) === idx) + .join('')} + /> + + ))} + + + {paths.map(({ d, c: { id } }, i) => ( + + ))} + [`M${prev.x} ${prev.y}h.01`, `M${next.x} ${next.y}h.01`]) + .filter((val, idx, arr) => arr.indexOf(val) === idx) + .join('')} + /> + + + ); +}; + +const ColoredPath = ({ + colors, + paths, + ...props +}: { paths: Path[]; colors: string[] } & PathProps) => ( + + {paths.map(({ d, c }, i) => ( + + ))} + +); + +const ControlPath = ({ + paths, + radius, + pointSize, + ...props +}: { pointSize: number; paths: Path[]; radius: number } & PathProps< + 'stroke' | 'strokeWidth', + 'd' +>) => { + const controlPaths = paths.map((path, i) => { + const element = paths.filter((p) => p.c.id === path.c.id); + const lastElement = element.at(-1)?.next; + assert(lastElement); + const isClosed = element[0].prev.x === lastElement.x && element[0].prev.y === lastElement.y; + const showMarker = !['rect', 'circle', 'ellipse'].includes(path.c.name); + return { + ...path, + showMarker, + startMarker: showMarker && path.isStart && !isClosed, + endMarker: showMarker && paths[i + 1]?.isStart !== false && !isClosed, + }; + }); + return ( + <> + + {controlPaths.map(({ prev, next, showMarker }, i) => { + return ( + showMarker && ( + + + + + + ) + ); + })} + + + {controlPaths.map(({ d, showMarker }, i) => ( + + ))} + + + + showMarker ? [`M${prev.x} ${prev.y}h.01`, `M${next.x} ${next.y}h.01`] : [] + ) + .join('')} + /> + {controlPaths.map(({ d, prev, next, startMarker, endMarker }, i) => ( + + {startMarker && } + {endMarker && } + + ))} + + + ); +}; + +const SvgPreview = React.forwardRef( + ({ src, showGrid = false }, ref) => { + const paths = getPaths(src); + const darkModeCss = `@media screen and (prefers-color-scheme: dark) { + .svg-preview-grid-group, + .svg-preview-shadow-mask-group, + .svg-preview-shadow-group { + stroke: #fff; + } +}`; + return ( + + + {showGrid && } + + + + + ); + } +); + +export default SvgPreview; diff --git a/site/src/components/SvgPreview/types.ts b/site/src/components/SvgPreview/types.ts new file mode 100644 index 000000000..66af74a94 --- /dev/null +++ b/site/src/components/SvgPreview/types.ts @@ -0,0 +1,21 @@ +import { SVGProps } from 'react'; +import { getCommands } from './utils'; + +export type Point = { x: number; y: number }; + +export type Path = { + d: string; + prev: Point; + next: Point; + isStart: boolean; + c: ReturnType[number]; +}; + +export type PathProps< + RequiredProps extends keyof SVGProps, + NeverProps extends keyof SVGProps +> = Required, RequiredProps>> & + Omit< + React.SVGProps, + RequiredProps & NeverProps + >; diff --git a/site/src/components/SvgPreview/utils.ts b/site/src/components/SvgPreview/utils.ts new file mode 100644 index 000000000..7772f8f30 --- /dev/null +++ b/site/src/components/SvgPreview/utils.ts @@ -0,0 +1,186 @@ +import { INode, parseSync } from 'svgson'; +import toPath from 'element-to-path'; +import { SVGPathData, encodeSVGPath } from 'svg-pathdata'; +import { Path, Point } from './types'; + +function assertNever(x: never): never { + throw new Error('Unknown type: ' + x['type']); +} +export function assert(value: unknown): asserts value { + if (value === undefined) { + throw new Error('value must be defined'); + } +} + +const extractPaths = (node: INode): { d: string; name: typeof node.name }[] => { + if (/(rect|circle|ellipse|polygon|polyline|line|path)/.test(node.name)) { + return [{ d: toPath(node), name: node.name }]; + } else if (node.children && Array.isArray(node.children)) { + return node.children.flatMap(extractPaths); + } + + return []; +}; + +export const getCommands = (src: string) => + extractPaths(parseSync(src)).flatMap(({ d, name }, idx) => + new SVGPathData(d).toAbs().commands.map((c) => ({ ...c, id: idx, name })) + ); + +export const getPaths = (src: string) => { + const commands = getCommands(src); + const paths: Path[] = []; + let prev: Point | undefined = undefined; + let start: Point | undefined = undefined; + const addPath = (c: (typeof commands)[number], next: Point, d?: string) => { + assert(prev); + paths.push({ + c, + d: d || `M${prev.x} ${prev.y}L${next.x} ${next.y}`, + prev, + next, + isStart: start === prev, + }); + prev = next; + }; + let prevCP: Point | undefined = undefined; + for (let i = 0; i < commands.length; i++) { + const previousCommand = commands[i - 1]; + const c = commands[i]; + switch (c.type) { + case SVGPathData.MOVE_TO: { + prev = c; + start = c; + break; + } + case SVGPathData.LINE_TO: { + assert(prev); + addPath(c, c); + break; + } + case SVGPathData.HORIZ_LINE_TO: { + assert(prev); + addPath(c, { x: c.x, y: prev.y }); + break; + } + case SVGPathData.VERT_LINE_TO: { + assert(prev); + addPath(c, { x: prev.x, y: c.y }); + break; + } + case SVGPathData.CLOSE_PATH: { + assert(prev); + assert(start); + addPath(c, start); + start = undefined; + break; + } + case SVGPathData.CURVE_TO: { + assert(prev); + addPath(c, c, `M ${prev.x},${prev.y} ${encodeSVGPath(c)}`); + break; + } + case SVGPathData.SMOOTH_CURVE_TO: { + assert(prev); + assert(previousCommand); + const reflectedCp1 = { + x: + previousCommand && + (previousCommand.type === SVGPathData.SMOOTH_CURVE_TO || + previousCommand.type === SVGPathData.CURVE_TO) + ? previousCommand.relative + ? previousCommand.x2 - previousCommand.x + : previousCommand.x2 - prev.x + : 0, + y: + previousCommand && + (previousCommand.type === SVGPathData.SMOOTH_CURVE_TO || + previousCommand.type === SVGPathData.CURVE_TO) + ? previousCommand.relative + ? previousCommand.y2 - previousCommand.y + : previousCommand.y2 - prev.y + : 0, + }; + addPath( + c, + c, + `M ${prev.x},${prev.y} ${encodeSVGPath({ + type: SVGPathData.CURVE_TO, + relative: false, + x: c.x, + y: c.y, + x1: prev.x - reflectedCp1.x, + y1: prev.y - reflectedCp1.y, + x2: c.x2, + y2: c.y2, + })}` + ); + break; + } + case SVGPathData.QUAD_TO: { + assert(prev); + addPath(c, c, `M ${prev.x},${prev.y} ${encodeSVGPath(c)}`); + break; + } + case SVGPathData.SMOOTH_QUAD_TO: { + assert(prev); + const backTrackCP = ( + index: number, + currentPoint: { x: number; y: number } + ): { x: number; y: number } => { + const previousCommand = commands[index - 1]; + if (!previousCommand) { + return currentPoint; + } + if (previousCommand.type === SVGPathData.QUAD_TO) { + return { + x: previousCommand.relative + ? currentPoint.x - (previousCommand.x1 - previousCommand.x) + : currentPoint.x - (previousCommand.x1 - currentPoint.x), + y: previousCommand.relative + ? currentPoint.y - (previousCommand.y1 - previousCommand.y) + : currentPoint.y - (previousCommand.y1 - currentPoint.y), + }; + } + if (previousCommand.type === SVGPathData.SMOOTH_QUAD_TO) { + if (!prevCP) { + return currentPoint; + } + return { + x: currentPoint.x - (prevCP.x - currentPoint.x), + y: currentPoint.y - (prevCP.y - currentPoint.y), + }; + } + return currentPoint; + }; + prevCP = backTrackCP(i, prev); + addPath( + c, + c, + `M ${prev.x},${prev.y} ${encodeSVGPath({ + type: SVGPathData.QUAD_TO, + relative: false, + x: c.x, + y: c.y, + x1: prevCP.x, + y1: prevCP.y, + })}` + ); + break; + } + case SVGPathData.ARC: { + assert(prev); + addPath( + c, + c, + `M ${prev.x},${prev.y} A ${c.rX} ${c.rY} ${c.xRot} ${c.lArcFlag} ${c.sweepFlag} ${c.x} ${c.y}` + ); + break; + } + default: { + assertNever(c); + } + } + } + return paths; +}; diff --git a/site/src/pages/api/gh-icon/[data].tsx b/site/src/pages/api/gh-icon/[data].tsx new file mode 100644 index 000000000..dde8ca233 --- /dev/null +++ b/site/src/pages/api/gh-icon/[data].tsx @@ -0,0 +1,19 @@ +import SvgPreview from '../../../components/SvgPreview'; + +export default async function handler(req, res) { + // ReactDOMServer needs to be imported dynamically + // https://github.com/vercel/next.js/issues/43810 + const ReactDOMServer = (await import('react-dom/server')).default; + + const url = req.url.split('/').at(-1); + const data = url.slice(0, -4); + const src = Buffer.from(data, 'base64').toString('utf8'); + + const svg = Buffer.from( + ReactDOMServer.renderToString() + ); + + res.setHeader('Cache-Control', 'public,max-age=31536000'); + res.setHeader('Content-Type', 'image/svg+xml'); + res.status(200).end(svg); +}