Skip to content
Draft
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
408 changes: 393 additions & 15 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/octicons-react-symbols/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/generated/**
40 changes: 40 additions & 0 deletions packages/octicons-react-symbols/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@primer/octicons-react-symbols",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"types": "./dist/generated/index.d.ts",
"default": "./dist/generated/index.js"
},
"scripts": {
"build": "node script/build.ts && rolldown -c",
"clean": "rimraf dist",
"lint:npm": "publint --types",
"type-check": "tsc --noEmit",
"watch": "rolldown -c -w"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/generator": "^7.28.5",
"@babel/plugin-transform-runtime": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@primer/octicons": "^19.21.1",
"@rollup/plugin-babel": "^6.1.0",
"@types/babel__core": "^7.20.5",
"@types/babel__generator": "^7.27.0",
"babel-plugin-react-compiler": "^1.0.0",
"change-case": "^5.4.4",
"publint": "^0.3.16",
"react": "^18.3.1",
"rimraf": "^5.0.5",
"rolldown": "^1.0.0-beta.57",
"rollup-plugin-typescript2": "^0.36.0",
"typescript": "^5.9.3"
},
"dependencies": {
"@babel/runtime": "^7.28.4",
"react-compiler-runtime": "^1.0.0"
}
}
47 changes: 47 additions & 0 deletions packages/octicons-react-symbols/rolldown.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {defineConfig} from 'rolldown/config'
import babel from '@rollup/plugin-babel'
import typescript from 'rollup-plugin-typescript2'
import packageJson from './package.json' with {type: 'json'}

const external = [
...Object.keys(packageJson.peerDependencies ?? {}),
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.devDependencies ?? {}),
].map(name => new RegExp(`^${name}(/.*)?`))

export default defineConfig({
input: ['./src/generated/index.ts'],
external,
plugins: [
typescript({
tsconfig: 'tsconfig.build.json',
}),
babel({
presets: [
'@babel/preset-typescript',
[
'@babel/preset-react',
{
runtime: 'automatic',
},
],
],
plugins: [
[
'babel-plugin-react-compiler',
{
target: '18',
},
],
'@babel/plugin-transform-runtime',
],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
babelHelpers: 'runtime',
}),
],
output: {
dir: './dist',
preserveModules: true,
preserveModulesRoot: 'src',
},
})
242 changes: 242 additions & 0 deletions packages/octicons-react-symbols/script/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import babel from '@babel/core'
import {generate} from '@babel/generator'
import {pascalCase} from 'change-case'
import data from '@primer/octicons/build/data.json' with {type: 'json'}

const {types: t} = babel
const SOURCE_DIRECTORY = path.resolve(import.meta.dirname, '../src')
const GENERATED_DIRECTORY = path.join(SOURCE_DIRECTORY, 'generated')

await fs.mkdir(GENERATED_DIRECTORY, {recursive: true})

// Each icon is around 2-3kB in size, create modules around 100kB in size
const BUCKET_SIZE = 40
const modules = partition(Object.values(data), BUCKET_SIZE).map((icons, index) => {
const filepath = path.join(GENERATED_DIRECTORY, `icons-${(index + 1).toString().padStart(2, '0')}.tsx`)

const propsImportSpecifier = t.importSpecifier(
t.identifier('OcticonReferenceProps'),
t.identifier('OcticonReferenceProps'),
)
propsImportSpecifier.importKind = 'type'

const imports = [
// import {forwardRef} from 'react'
t.importDeclaration(
[t.importSpecifier(t.identifier('forwardRef'), t.identifier('forwardRef'))],
t.stringLiteral('react'),
),

// import {Icon} from '../Icon'
t.importDeclaration([t.importSpecifier(t.identifier('Icon'), t.identifier('Icon'))], t.stringLiteral('../Icon')),

// import type {OcticonReferenceProps} from '../types'
t.importDeclaration([propsImportSpecifier], t.stringLiteral('../types')),
]

const components = icons.flatMap(icon => {
const symbolName = `${pascalCase(icon.name)}Symbol`
const symbols = Object.entries(icon.heights).map(([height, size]) => {
const id = `symbol-octicon-${icon.name}-${height}`

const jsx = t.jsxElement(
t.jsxOpeningElement(t.jsxIdentifier('symbol'), [
t.jsxAttribute(t.jsxIdentifier('id'), t.stringLiteral(id)),
t.jsxAttribute(t.jsxIdentifier('viewBox'), t.stringLiteral(`0 0 ${size.width} ${height}`)),
]),
t.jsxClosingElement(t.jsxIdentifier('symbol')),
svgToJSX(size.ast),
)

return {
id,
height,
width: size.width,
jsx,
}
})
const symbolComponent = t.functionDeclaration(
t.identifier(symbolName),
[],
t.blockStatement([
t.returnStatement(
symbols.length === 1
? symbols[0].jsx
: t.jsxFragment(
t.jsxOpeningFragment(),
t.jsxClosingFragment(),
symbols.map(symbol => symbol.jsx),
),
),
]),
)

const referenceName = `${pascalCase(icon.name)}Icon`
const forwardRef = t.callExpression(t.identifier('forwardRef'), [
t.functionExpression(
t.identifier(referenceName),
[t.identifier('props'), t.identifier('ref')],
t.blockStatement([
t.returnStatement(
t.jsxElement(
t.jsxOpeningElement(
t.jsxIdentifier('Icon'),
[
t.jsxSpreadAttribute(t.identifier('props')),
t.jsxAttribute(t.jsxIdentifier('ref'), t.jsxExpressionContainer(t.identifier('ref'))),
t.jsxAttribute(
t.jsxIdentifier('sizes'),
t.jsxExpressionContainer(
t.objectExpression(
symbols.map(symbol =>
t.objectProperty(
t.stringLiteral(symbol.height),
t.objectExpression([
t.objectProperty(t.stringLiteral('width'), t.numericLiteral(symbol.width)),
t.objectProperty(t.stringLiteral('id'), t.stringLiteral(symbol.id)),
]),
),
),
),
),
),
],
true,
),
t.jsxClosingElement(t.jsxIdentifier('Icon')),
[],
),
),
]),
),
])

forwardRef.typeParameters = t.tsTypeParameterInstantiation([
t.tsTypeReference(t.identifier('SVGSVGElement')),
t.tsTypeReference(t.identifier('OcticonReferenceProps')),
])

const reference = t.variableDeclaration('const', [t.variableDeclarator(t.identifier(referenceName), forwardRef)])

return [t.exportNamedDeclaration(symbolComponent), t.exportNamedDeclaration(reference)]
})

const body = [...imports, ...components]
const program = t.addComment(
t.program(body),
'leading',
`This file is auto-generated by 'script/build.ts'. Do not edit directly.`,
)

return {
filepath,
contents: generate(program).code,
exports: icons.flatMap(icon => [`${pascalCase(icon.name)}Icon`, `${pascalCase(icon.name)}Symbol`]),
}
})

for (const mod of modules) {
await fs.writeFile(mod.filepath, mod.contents)
}

const indexFilePath = path.join(SOURCE_DIRECTORY, 'generated', 'index.ts')

const octiconsSymbolsPropsExportSpecifier = t.exportSpecifier(
t.identifier('OcticonSymbolsProps'),
t.identifier('OcticonSymbolsProps'),
)
octiconsSymbolsPropsExportSpecifier.exportKind = 'type'

const octiconsReferencePropsExportSpecifier = t.exportSpecifier(
t.identifier('OcticonReferenceProps'),
t.identifier('OcticonReferenceProps'),
)
octiconsReferencePropsExportSpecifier.exportKind = 'type'

const index = t.addComment(
t.program([
t.exportNamedDeclaration(
null,
[
t.exportSpecifier(t.identifier('OcticonSymbols'), t.identifier('OcticonSymbols')),
octiconsSymbolsPropsExportSpecifier,
],
t.stringLiteral('../OcticonSymbols'),
),
t.exportNamedDeclaration(null, [octiconsReferencePropsExportSpecifier], t.stringLiteral('../types')),
...modules.map(mod => {
return t.exportNamedDeclaration(
null,
mod.exports.map(exportName => t.exportSpecifier(t.identifier(exportName), t.identifier(exportName))),
t.stringLiteral(`./${path.basename(mod.filepath, path.extname(mod.filepath))}`),
)
}),
]),
'leading',
`This file is auto-generated by 'script/build.ts'. Do not edit directly.`,
)

await fs.writeFile(indexFilePath, generate(index).code)

function partition<T>(items: Array<T>, size: number): Array<Array<T>> {
const result: Array<Array<T>> = []
let bucket: Array<T> = []
let count = 0

for (const item of items) {
if (count >= size) {
result.push(bucket)
bucket = []
count = 0
}

bucket.push(item)
count++
}

if (bucket.length > 0) {
result.push(bucket)
}

return result
}

type SVGASTNode = {
type: string
name: string
attributes: Record<string, string>
children: Array<SVGASTNode>
}

function svgToJSX(node: SVGASTNode): Array<babel.types.JSXElement> {
if (node.type === 'element') {
const children = node.children.map(svgToJSX)

if (node.name === 'svg') {
if (children.length === 0) {
throw new Error(`No children available for icon`)
}

return children.flat()
}

const attrs = Object.entries(node.attributes).map(([key, value]) => {
if (typeof value !== 'string') {
throw new Error(`Unknown value type: ${value}`)
}
return t.jsxAttribute(t.jsxIdentifier(key), t.stringLiteral(value))
})
const openingElement = t.jsxOpeningElement(t.jsxIdentifier(node.name), attrs, children.length === 0)
const closingElement = t.jsxClosingElement(t.jsxIdentifier(node.name))

if (children.length > 0) {
return [t.jsxElement(openingElement, closingElement, children.flat(), false)]
}

return [t.jsxElement(openingElement, closingElement, [], true)]
}

throw new Error(`Unknown type: ${node.type}`)
}
Loading
Loading