Compare commits

...

1 Commits

Author SHA1 Message Date
shadcn
ecbace99d9 feat: shadcn info preset code 2026-04-27 11:05:25 +04:00
7 changed files with 1663 additions and 96 deletions

View File

@@ -0,0 +1,473 @@
export const TAILWIND_COLOR_SCALES = [
"50",
"100",
"200",
"300",
"400",
"500",
"600",
"700",
"800",
"900",
"950",
] as const
export const TAILWIND_COLOR_FAMILIES = [
"red",
"orange",
"amber",
"yellow",
"lime",
"green",
"emerald",
"teal",
"cyan",
"sky",
"blue",
"indigo",
"violet",
"purple",
"fuchsia",
"pink",
"rose",
"slate",
"gray",
"zinc",
"neutral",
"stone",
"mauve",
"olive",
"mist",
"taupe",
] as const
export type TailwindColorScale = (typeof TAILWIND_COLOR_SCALES)[number]
export type TailwindColorFamily = (typeof TAILWIND_COLOR_FAMILIES)[number]
function parseCatalog<T>(catalog: string) {
return JSON.parse(catalog) as T
}
export const TAILWIND_COLORS = parseCatalog<
Record<TailwindColorFamily, Record<TailwindColorScale, string>>
>(String.raw`{
"red": {
"50": "oklch(97.1% 0.013 17.38)",
"100": "oklch(93.6% 0.032 17.717)",
"200": "oklch(88.5% 0.062 18.334)",
"300": "oklch(80.8% 0.114 19.571)",
"400": "oklch(70.4% 0.191 22.216)",
"500": "oklch(63.7% 0.237 25.331)",
"600": "oklch(57.7% 0.245 27.325)",
"700": "oklch(50.5% 0.213 27.518)",
"800": "oklch(44.4% 0.177 26.899)",
"900": "oklch(39.6% 0.141 25.723)",
"950": "oklch(25.8% 0.092 26.042)"
},
"orange": {
"50": "oklch(98% 0.016 73.684)",
"100": "oklch(95.4% 0.038 75.164)",
"200": "oklch(90.1% 0.076 70.697)",
"300": "oklch(83.7% 0.128 66.29)",
"400": "oklch(75% 0.183 55.934)",
"500": "oklch(70.5% 0.213 47.604)",
"600": "oklch(64.6% 0.222 41.116)",
"700": "oklch(55.3% 0.195 38.402)",
"800": "oklch(47% 0.157 37.304)",
"900": "oklch(40.8% 0.123 38.172)",
"950": "oklch(26.6% 0.079 36.259)"
},
"amber": {
"50": "oklch(98.7% 0.022 95.277)",
"100": "oklch(96.2% 0.059 95.617)",
"200": "oklch(92.4% 0.12 95.746)",
"300": "oklch(87.9% 0.169 91.605)",
"400": "oklch(82.8% 0.189 84.429)",
"500": "oklch(76.9% 0.188 70.08)",
"600": "oklch(66.6% 0.179 58.318)",
"700": "oklch(55.5% 0.163 48.998)",
"800": "oklch(47.3% 0.137 46.201)",
"900": "oklch(41.4% 0.112 45.904)",
"950": "oklch(27.9% 0.077 45.635)"
},
"yellow": {
"50": "oklch(98.7% 0.026 102.212)",
"100": "oklch(97.3% 0.071 103.193)",
"200": "oklch(94.5% 0.129 101.54)",
"300": "oklch(90.5% 0.182 98.111)",
"400": "oklch(85.2% 0.199 91.936)",
"500": "oklch(79.5% 0.184 86.047)",
"600": "oklch(68.1% 0.162 75.834)",
"700": "oklch(55.4% 0.135 66.442)",
"800": "oklch(47.6% 0.114 61.907)",
"900": "oklch(42.1% 0.095 57.708)",
"950": "oklch(28.6% 0.066 53.813)"
},
"lime": {
"50": "oklch(98.6% 0.031 120.757)",
"100": "oklch(96.7% 0.067 122.328)",
"200": "oklch(93.8% 0.127 124.321)",
"300": "oklch(89.7% 0.196 126.665)",
"400": "oklch(84.1% 0.238 128.85)",
"500": "oklch(76.8% 0.233 130.85)",
"600": "oklch(64.8% 0.2 131.684)",
"700": "oklch(53.2% 0.157 131.589)",
"800": "oklch(45.3% 0.124 130.933)",
"900": "oklch(40.5% 0.101 131.063)",
"950": "oklch(27.4% 0.072 132.109)"
},
"green": {
"50": "oklch(98.2% 0.018 155.826)",
"100": "oklch(96.2% 0.044 156.743)",
"200": "oklch(92.5% 0.084 155.995)",
"300": "oklch(87.1% 0.15 154.449)",
"400": "oklch(79.2% 0.209 151.711)",
"500": "oklch(72.3% 0.219 149.579)",
"600": "oklch(62.7% 0.194 149.214)",
"700": "oklch(52.7% 0.154 150.069)",
"800": "oklch(44.8% 0.119 151.328)",
"900": "oklch(39.3% 0.095 152.535)",
"950": "oklch(26.6% 0.065 152.934)"
},
"emerald": {
"50": "oklch(97.9% 0.021 166.113)",
"100": "oklch(95% 0.052 163.051)",
"200": "oklch(90.5% 0.093 164.15)",
"300": "oklch(84.5% 0.143 164.978)",
"400": "oklch(76.5% 0.177 163.223)",
"500": "oklch(69.6% 0.17 162.48)",
"600": "oklch(59.6% 0.145 163.225)",
"700": "oklch(50.8% 0.118 165.612)",
"800": "oklch(43.2% 0.095 166.913)",
"900": "oklch(37.8% 0.077 168.94)",
"950": "oklch(26.2% 0.051 172.552)"
},
"teal": {
"50": "oklch(98.4% 0.014 180.72)",
"100": "oklch(95.3% 0.051 180.801)",
"200": "oklch(91% 0.096 180.426)",
"300": "oklch(85.5% 0.138 181.071)",
"400": "oklch(77.7% 0.152 181.912)",
"500": "oklch(70.4% 0.14 182.503)",
"600": "oklch(60% 0.118 184.704)",
"700": "oklch(51.1% 0.096 186.391)",
"800": "oklch(43.7% 0.078 188.216)",
"900": "oklch(38.6% 0.063 188.416)",
"950": "oklch(27.7% 0.046 192.524)"
},
"cyan": {
"50": "oklch(98.4% 0.019 200.873)",
"100": "oklch(95.6% 0.045 203.388)",
"200": "oklch(91.7% 0.08 205.041)",
"300": "oklch(86.5% 0.127 207.078)",
"400": "oklch(78.9% 0.154 211.53)",
"500": "oklch(71.5% 0.143 215.221)",
"600": "oklch(60.9% 0.126 221.723)",
"700": "oklch(52% 0.105 223.128)",
"800": "oklch(45% 0.085 224.283)",
"900": "oklch(39.8% 0.07 227.392)",
"950": "oklch(30.2% 0.056 229.695)"
},
"sky": {
"50": "oklch(97.7% 0.013 236.62)",
"100": "oklch(95.1% 0.026 236.824)",
"200": "oklch(90.1% 0.058 230.902)",
"300": "oklch(82.8% 0.111 230.318)",
"400": "oklch(74.6% 0.16 232.661)",
"500": "oklch(68.5% 0.169 237.323)",
"600": "oklch(58.8% 0.158 241.966)",
"700": "oklch(50% 0.134 242.749)",
"800": "oklch(44.3% 0.11 240.79)",
"900": "oklch(39.1% 0.09 240.876)",
"950": "oklch(29.3% 0.066 243.157)"
},
"blue": {
"50": "oklch(97% 0.014 254.604)",
"100": "oklch(93.2% 0.032 255.585)",
"200": "oklch(88.2% 0.059 254.128)",
"300": "oklch(80.9% 0.105 251.813)",
"400": "oklch(70.7% 0.165 254.624)",
"500": "oklch(62.3% 0.214 259.815)",
"600": "oklch(54.6% 0.245 262.881)",
"700": "oklch(48.8% 0.243 264.376)",
"800": "oklch(42.4% 0.199 265.638)",
"900": "oklch(37.9% 0.146 265.522)",
"950": "oklch(28.2% 0.091 267.935)"
},
"indigo": {
"50": "oklch(96.2% 0.018 272.314)",
"100": "oklch(93% 0.034 272.788)",
"200": "oklch(87% 0.065 274.039)",
"300": "oklch(78.5% 0.115 274.713)",
"400": "oklch(67.3% 0.182 276.935)",
"500": "oklch(58.5% 0.233 277.117)",
"600": "oklch(51.1% 0.262 276.966)",
"700": "oklch(45.7% 0.24 277.023)",
"800": "oklch(39.8% 0.195 277.366)",
"900": "oklch(35.9% 0.144 278.697)",
"950": "oklch(25.7% 0.09 281.288)"
},
"violet": {
"50": "oklch(96.9% 0.016 293.756)",
"100": "oklch(94.3% 0.029 294.588)",
"200": "oklch(89.4% 0.057 293.283)",
"300": "oklch(81.1% 0.111 293.571)",
"400": "oklch(70.2% 0.183 293.541)",
"500": "oklch(60.6% 0.25 292.717)",
"600": "oklch(54.1% 0.281 293.009)",
"700": "oklch(49.1% 0.27 292.581)",
"800": "oklch(43.2% 0.232 292.759)",
"900": "oklch(38% 0.189 293.745)",
"950": "oklch(28.3% 0.141 291.089)"
},
"purple": {
"50": "oklch(97.7% 0.014 308.299)",
"100": "oklch(94.6% 0.033 307.174)",
"200": "oklch(90.2% 0.063 306.703)",
"300": "oklch(82.7% 0.119 306.383)",
"400": "oklch(71.4% 0.203 305.504)",
"500": "oklch(62.7% 0.265 303.9)",
"600": "oklch(55.8% 0.288 302.321)",
"700": "oklch(49.6% 0.265 301.924)",
"800": "oklch(43.8% 0.218 303.724)",
"900": "oklch(38.1% 0.176 304.987)",
"950": "oklch(29.1% 0.149 302.717)"
},
"fuchsia": {
"50": "oklch(97.7% 0.017 320.058)",
"100": "oklch(95.2% 0.037 318.852)",
"200": "oklch(90.3% 0.076 319.62)",
"300": "oklch(83.3% 0.145 321.434)",
"400": "oklch(74% 0.238 322.16)",
"500": "oklch(66.7% 0.295 322.15)",
"600": "oklch(59.1% 0.293 322.896)",
"700": "oklch(51.8% 0.253 323.949)",
"800": "oklch(45.2% 0.211 324.591)",
"900": "oklch(40.1% 0.17 325.612)",
"950": "oklch(29.3% 0.136 325.661)"
},
"pink": {
"50": "oklch(97.1% 0.014 343.198)",
"100": "oklch(94.8% 0.028 342.258)",
"200": "oklch(89.9% 0.061 343.231)",
"300": "oklch(82.3% 0.12 346.018)",
"400": "oklch(71.8% 0.202 349.761)",
"500": "oklch(65.6% 0.241 354.308)",
"600": "oklch(59.2% 0.249 0.584)",
"700": "oklch(52.5% 0.223 3.958)",
"800": "oklch(45.9% 0.187 3.815)",
"900": "oklch(40.8% 0.153 2.432)",
"950": "oklch(28.4% 0.109 3.907)"
},
"rose": {
"50": "oklch(96.9% 0.015 12.422)",
"100": "oklch(94.1% 0.03 12.58)",
"200": "oklch(89.2% 0.058 10.001)",
"300": "oklch(81% 0.117 11.638)",
"400": "oklch(71.2% 0.194 13.428)",
"500": "oklch(64.5% 0.246 16.439)",
"600": "oklch(58.6% 0.253 17.585)",
"700": "oklch(51.4% 0.222 16.935)",
"800": "oklch(45.5% 0.188 13.697)",
"900": "oklch(41% 0.159 10.272)",
"950": "oklch(27.1% 0.105 12.094)"
},
"slate": {
"50": "oklch(98.4% 0.003 247.858)",
"100": "oklch(96.8% 0.007 247.896)",
"200": "oklch(92.9% 0.013 255.508)",
"300": "oklch(86.9% 0.022 252.894)",
"400": "oklch(70.4% 0.04 256.788)",
"500": "oklch(55.4% 0.046 257.417)",
"600": "oklch(44.6% 0.043 257.281)",
"700": "oklch(37.2% 0.044 257.287)",
"800": "oklch(27.9% 0.041 260.031)",
"900": "oklch(20.8% 0.042 265.755)",
"950": "oklch(12.9% 0.042 264.695)"
},
"gray": {
"50": "oklch(98.5% 0.002 247.839)",
"100": "oklch(96.7% 0.003 264.542)",
"200": "oklch(92.8% 0.006 264.531)",
"300": "oklch(87.2% 0.01 258.338)",
"400": "oklch(70.7% 0.022 261.325)",
"500": "oklch(55.1% 0.027 264.364)",
"600": "oklch(44.6% 0.03 256.802)",
"700": "oklch(37.3% 0.034 259.733)",
"800": "oklch(27.8% 0.033 256.848)",
"900": "oklch(21% 0.034 264.665)",
"950": "oklch(13% 0.028 261.692)"
},
"zinc": {
"50": "oklch(98.5% 0 0)",
"100": "oklch(96.7% 0.001 286.375)",
"200": "oklch(92% 0.004 286.32)",
"300": "oklch(87.1% 0.006 286.286)",
"400": "oklch(70.5% 0.015 286.067)",
"500": "oklch(55.2% 0.016 285.938)",
"600": "oklch(44.2% 0.017 285.786)",
"700": "oklch(37% 0.013 285.805)",
"800": "oklch(27.4% 0.006 286.033)",
"900": "oklch(21% 0.006 285.885)",
"950": "oklch(14.1% 0.005 285.823)"
},
"neutral": {
"50": "oklch(98.5% 0 0)",
"100": "oklch(97% 0 0)",
"200": "oklch(92.2% 0 0)",
"300": "oklch(87% 0 0)",
"400": "oklch(70.8% 0 0)",
"500": "oklch(55.6% 0 0)",
"600": "oklch(43.9% 0 0)",
"700": "oklch(37.1% 0 0)",
"800": "oklch(26.9% 0 0)",
"900": "oklch(20.5% 0 0)",
"950": "oklch(14.5% 0 0)"
},
"stone": {
"50": "oklch(98.5% 0.001 106.423)",
"100": "oklch(97% 0.001 106.424)",
"200": "oklch(92.3% 0.003 48.717)",
"300": "oklch(86.9% 0.005 56.366)",
"400": "oklch(70.9% 0.01 56.259)",
"500": "oklch(55.3% 0.013 58.071)",
"600": "oklch(44.4% 0.011 73.639)",
"700": "oklch(37.4% 0.01 67.558)",
"800": "oklch(26.8% 0.007 34.298)",
"900": "oklch(21.6% 0.006 56.043)",
"950": "oklch(14.7% 0.004 49.25)"
},
"mauve": {
"50": "oklch(98.5% 0 0)",
"100": "oklch(96% 0.003 325.6)",
"200": "oklch(92.2% 0.005 325.62)",
"300": "oklch(86.5% 0.012 325.68)",
"400": "oklch(71.1% 0.019 323.02)",
"500": "oklch(54.2% 0.034 322.5)",
"600": "oklch(43.5% 0.029 321.78)",
"700": "oklch(36.4% 0.029 323.89)",
"800": "oklch(26.3% 0.024 320.12)",
"900": "oklch(21.2% 0.019 322.12)",
"950": "oklch(14.5% 0.008 326)"
},
"olive": {
"50": "oklch(98.8% 0.003 106.5)",
"100": "oklch(96.6% 0.005 106.5)",
"200": "oklch(93% 0.007 106.5)",
"300": "oklch(88% 0.011 106.6)",
"400": "oklch(73.7% 0.021 106.9)",
"500": "oklch(58% 0.031 107.3)",
"600": "oklch(46.6% 0.025 107.3)",
"700": "oklch(39.4% 0.023 107.4)",
"800": "oklch(28.6% 0.016 107.4)",
"900": "oklch(22.8% 0.013 107.4)",
"950": "oklch(15.3% 0.006 107.1)"
},
"mist": {
"50": "oklch(98.7% 0.002 197.1)",
"100": "oklch(96.3% 0.002 197.1)",
"200": "oklch(92.5% 0.005 214.3)",
"300": "oklch(87.2% 0.007 219.6)",
"400": "oklch(72.3% 0.014 214.4)",
"500": "oklch(56% 0.021 213.5)",
"600": "oklch(45% 0.017 213.2)",
"700": "oklch(37.8% 0.015 216)",
"800": "oklch(27.5% 0.011 216.9)",
"900": "oklch(21.8% 0.008 223.9)",
"950": "oklch(14.8% 0.004 228.8)"
},
"taupe": {
"50": "oklch(98.6% 0.002 67.8)",
"100": "oklch(96% 0.002 17.2)",
"200": "oklch(92.2% 0.005 34.3)",
"300": "oklch(86.8% 0.007 39.5)",
"400": "oklch(71.4% 0.014 41.2)",
"500": "oklch(54.7% 0.021 43.1)",
"600": "oklch(43.8% 0.017 39.3)",
"700": "oklch(36.7% 0.016 35.7)",
"800": "oklch(26.8% 0.011 36.5)",
"900": "oklch(21.4% 0.009 43.1)",
"950": "oklch(14.7% 0.004 49.3)"
}
}`)
const LEGACY_COLOR_FAMILY_ALIASES: Record<string, TailwindColorFamily> = {
"220.9 39.3% 11%": "gray",
"210 20% 98%": "gray",
"12 76% 61%": "gray",
"220 70% 50%": "gray",
}
const TAILWIND_COLOR_VALUE_TO_FAMILY = new Map<string, TailwindColorFamily>()
for (const family of TAILWIND_COLOR_FAMILIES) {
for (const scale of TAILWIND_COLOR_SCALES) {
TAILWIND_COLOR_VALUE_TO_FAMILY.set(
normalizeColorValue(TAILWIND_COLORS[family][scale]),
family
)
}
}
export function findTailwindColorFamily(
value: string | undefined
) {
const normalized = normalizeColorValue(value)
if (!normalized) {
return null
}
return (
TAILWIND_COLOR_VALUE_TO_FAMILY.get(normalized) ??
LEGACY_COLOR_FAMILY_ALIASES[normalized] ??
null
)
}
export function normalizeColorValue(value: string | undefined) {
if (!value) {
return ""
}
const normalized = value.trim().replace(/\s+/g, " ").toLowerCase()
if (!normalized.startsWith("oklch(") || !normalized.endsWith(")")) {
return normalized
}
const inner = normalized.slice(6, -1).trim()
const [color] = inner.split(/\s*\/\s*/)
const parts = color.split(/\s+/)
if (parts.length < 3) {
return normalized
}
const lightness = normalizeColorNumber(parts[0], { percentage: true })
const chroma = normalizeColorNumber(parts[1])
const hue = normalizeColorNumber(parts[2])
return `oklch(${lightness} ${chroma} ${hue})`
}
function normalizeColorNumber(
value: string,
options: {
percentage?: boolean
} = {}
) {
if (options.percentage && value.endsWith("%")) {
return formatColorNumber(Number.parseFloat(value) / 100)
}
return formatColorNumber(Number.parseFloat(value))
}
function formatColorNumber(value: number) {
if (Number.isNaN(value)) {
return ""
}
return Number(value.toFixed(12)).toString()
}

View File

@@ -1,5 +1,6 @@
import { existsSync } from "fs"
import path from "path"
import { resolveProjectPreset } from "@/src/preset/resolve"
import { SHADCN_URL } from "@/src/registry/constants"
import { getBase, getConfig } from "@/src/utils/get-config"
import {
@@ -64,7 +65,7 @@ export const info = new Command()
const config = await getConfig(cwd)
const components = await getProjectComponents(cwd)
const base = getBase(config?.style)
const data = collectInfo(projectInfo, config, components, base)
const data = await collectInfo(projectInfo, config, components, base)
if (opts.json) {
console.log(JSON.stringify(data, null, 2))
@@ -91,12 +92,16 @@ function getRegistries(
return result
}
function collectInfo(
export async function collectInfo(
projectInfo: ProjectInfo | null,
config: Awaited<ReturnType<typeof getConfig>>,
components: string[],
base: string
) {
const preset = config
? await resolveProjectPreset(config, projectInfo)
: null
return {
project: projectInfo
? {
@@ -142,6 +147,7 @@ function collectInfo(
registries: getRegistries(config.registries),
}
: null,
preset,
components,
links: {
docs: `${SHADCN_URL}/docs`,
@@ -153,7 +159,7 @@ function collectInfo(
}
}
function printInfo(data: ReturnType<typeof collectInfo>) {
export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
// Project.
logger.log(highlighter.info("Project"))
if (data.project) {
@@ -187,6 +193,29 @@ function printInfo(data: ReturnType<typeof collectInfo>) {
menuAccent: data.config.menuAccent ?? "-",
})
logger.break()
logger.log(highlighter.info("Preset"))
if (!data.preset?.code) {
printEntries({
"--preset": "-",
})
} else {
printEntries({
"--preset": data.preset.code,
url: `${SHADCN_URL}/create?preset=${data.preset.code}`,
style: data.preset.values?.style ?? "-",
baseColor: data.preset.values?.baseColor ?? "-",
theme: data.preset.values?.theme ?? "-",
chartColor: data.preset.values?.chartColor ?? "-",
iconLibrary: data.preset.values?.iconLibrary ?? "-",
font: data.preset.values?.font ?? "-",
fontHeading: data.preset.values?.fontHeading ?? "-",
radius: data.preset.values?.radius ?? "-",
menuAccent: data.preset.values?.menuAccent ?? "-",
menuColor: data.preset.values?.menuColor ?? "-",
})
}
// Aliases.
logger.break()
logger.log(highlighter.info("Aliases"))

View File

@@ -0,0 +1,101 @@
import type { PresetConfig } from "./preset"
export const DEFAULT_PRESETS = {
nova: {
title: "Nova",
description: "Lucide / Geist",
style: "nova",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "geist",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
vega: {
title: "Vega",
description: "Lucide / Inter",
style: "vega",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
maia: {
title: "Maia",
description: "Hugeicons / Figtree",
style: "maia",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "hugeicons",
font: "figtree",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
lyra: {
title: "Lyra",
description: "Phosphor / JetBrains Mono",
style: "lyra",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "phosphor",
font: "jetbrains-mono",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
mira: {
title: "Mira",
description: "Hugeicons / Inter",
style: "mira",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "hugeicons",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
luma: {
title: "Luma",
description: "Lucide / Inter",
style: "luma",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
} satisfies Record<
PresetConfig["style"],
PresetConfig & {
description: string
rtl: boolean
title: string
}
>

View File

@@ -79,6 +79,12 @@ describe("buildInitUrl", () => {
expect(parsed.searchParams.get("chartColor")).toBe("emerald")
})
it("should not include chartColor when it is neutral", () => {
const url = resolveInitUrl({ ...mockPreset, chartColor: "neutral" })
const parsed = new URL(url)
expect(parsed.searchParams.has("chartColor")).toBe(false)
})
it("should not include chartColor when not provided", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)

View File

@@ -11,99 +11,9 @@ import { ensureRegistriesInConfig } from "@/src/utils/registries"
import open from "open"
import prompts from "prompts"
import { type z } from "zod"
import { DEFAULT_PRESETS } from "./defaults"
export const DEFAULT_PRESETS = {
nova: {
title: "Nova",
description: "Lucide / Geist",
style: "nova",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "lucide",
font: "geist",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
vega: {
title: "Vega",
description: "Lucide / Inter",
style: "vega",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
maia: {
title: "Maia",
description: "Hugeicons / Figtree",
style: "maia",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "hugeicons",
font: "figtree",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
lyra: {
title: "Lyra",
description: "Phosphor / JetBrains Mono",
style: "lyra",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "phosphor",
font: "jetbrains-mono",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
mira: {
title: "Mira",
description: "Hugeicons / Inter",
style: "mira",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "hugeicons",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
luma: {
title: "Luma",
description: "Lucide / Inter",
style: "luma",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
}
export { DEFAULT_PRESETS } from "./defaults"
export function resolveCreateUrl(
searchParams?: Partial<{
@@ -188,7 +98,7 @@ export function resolveInitUrl(
radius: preset.radius,
})
if (preset.chartColor) {
if (preset.chartColor && preset.chartColor !== "neutral") {
params.set("chartColor", preset.chartColor)
}

View File

@@ -0,0 +1,275 @@
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { createConfig } from "@/src/utils/get-config"
import { FRAMEWORKS } from "@/src/utils/frameworks"
import { afterEach, describe, expect, it } from "vitest"
import { resolveProjectPreset } from "./resolve"
import { encodePreset, type PresetConfig } from "./preset"
const tempDirs: string[] = []
const presetCssWithHeadingFont = `@import "@fontsource-variable/inter";
@import "@fontsource-variable/lora";
:root {
--radius: 0.875rem;
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--chart-1: oklch(0.845 0.143 164.978);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.596 0.145 163.225);
--chart-4: oklch(0.508 0.118 165.612);
--chart-5: oklch(0.432 0.095 166.913);
}
.dark {
--primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--chart-1: oklch(0.845 0.143 164.978);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.596 0.145 163.225);
--chart-4: oklch(0.508 0.118 165.612);
--chart-5: oklch(0.432 0.095 166.913);
}
@theme inline {
--font-sans: "Inter Variable", sans-serif;
--font-heading: "Lora Variable", serif;
}`
async function createTestConfig(options: {
css: string
style?: string
baseColor?: string
iconLibrary?: string
menuColor?: PresetConfig["menuColor"]
menuAccent?: PresetConfig["menuAccent"]
files?: Record<string, string>
}) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "shadcn-preset-"))
tempDirs.push(tempDir)
const tailwindCss = path.join(tempDir, "globals.css")
await fs.writeFile(tailwindCss, options.css, "utf8")
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
const filePath = path.join(tempDir, relativePath)
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, content, "utf8")
}
return createConfig({
style: options.style ?? "base-luma",
tailwind: {
css: "globals.css",
baseColor: options.baseColor ?? "mist",
cssVariables: true,
prefix: "",
config: "",
},
iconLibrary: options.iconLibrary ?? "phosphor",
menuColor: options.menuColor ?? "inverted",
menuAccent: options.menuAccent ?? "bold",
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
resolvedPaths: {
cwd: tempDir,
tailwindCss,
},
})
}
afterEach(async () => {
await Promise.all(
tempDirs
.splice(0)
.map((dir) => fs.rm(dir, { recursive: true, force: true }))
)
})
describe("resolveProjectPreset", () => {
it("derives preset values and code from project css", async () => {
const config = await createTestConfig({
css: presetCssWithHeadingFont,
})
const result = await resolveProjectPreset(config)
expect(result.values).toEqual({
style: "luma",
baseColor: "mist",
theme: "blue",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
})
expect(result.code).toBe(encodePreset(result.values!))
})
it("falls back to preset defaults when css values cannot be matched", async () => {
const config = await createTestConfig({
css: `:root {
--radius: 1rem;
--primary: hotpink;
--chart-1: tomato;
}
.dark {
--primary: rebeccapurple;
--chart-1: orange;
}
@theme inline {
--font-sans: var(--font-sans);
}`,
menuAccent: "subtle",
menuColor: "default",
})
const result = await resolveProjectPreset(config)
expect(result.values).toEqual({
style: "luma",
baseColor: "mist",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "inherit",
radius: "default",
menuAccent: "subtle",
menuColor: "default",
})
expect(result.code).toBe(encodePreset(result.values!))
})
it("derives body and heading fonts from next/font declarations in layout.tsx", async () => {
const config = await createTestConfig({
css: `:root {
--radius: 0.875rem;
--primary: oklch(0.623 0.214 259.815);
--chart-1: oklch(0.696 0.17 162.48);
}
.dark {
--primary: oklch(0.623 0.214 259.815);
--chart-1: oklch(0.696 0.17 162.48);
}
@theme inline {
--font-sans: var(--font-sans);
--font-heading: var(--font-heading);
}`,
files: {
"src/app/layout.tsx": `import { Inter, Lora } from "next/font/google"
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
const loraHeading = Lora({ subsets: ["latin"], variable: "--font-heading" })
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={\`font-sans \${inter.variable} \${loraHeading.variable}\`}>
<body>{children}</body>
</html>
)
}`,
},
})
const result = await resolveProjectPreset(config, {
framework: FRAMEWORKS["next-app"],
isSrcDir: true,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: config.resolvedPaths.tailwindCss,
tailwindVersion: "v4",
frameworkVersion: "16.0.0",
aliasPrefix: "@",
})
expect(result.values).toMatchObject({
style: "luma",
theme: "blue",
chartColor: "emerald",
font: "inter",
fontHeading: "lora",
radius: "large",
})
expect(result.code).toBe(encodePreset(result.values!))
})
it("derives body font from next-pages _app when only one root font is defined", async () => {
const config = await createTestConfig({
css: `:root {
--radius: 0.625rem;
--primary: oklch(0.205 0 0);
--chart-1: oklch(0.87 0 0);
}
.dark {
--primary: oklch(0.205 0 0);
--chart-1: oklch(0.87 0 0);
}
@theme inline {
--font-sans: var(--font-sans);
}`,
files: {
"src/pages/_app.tsx": `import { Inter } from "next/font/google"
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
export default function App({ Component, pageProps }) {
return <main className={inter.variable}><Component {...pageProps} /></main>
}`,
},
})
const result = await resolveProjectPreset(config, {
framework: FRAMEWORKS["next-pages"],
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: config.resolvedPaths.tailwindCss,
tailwindVersion: "v4",
frameworkVersion: "16.0.0",
aliasPrefix: "@",
})
expect(result.values).toMatchObject({
style: "luma",
font: "inter",
fontHeading: "inherit",
})
expect(result.code).toBe(encodePreset(result.values!))
})
it("returns null for unsupported legacy styles", async () => {
const config = await createTestConfig({
style: "new-york",
css: `:root { --radius: 0.625rem; }`,
})
await expect(resolveProjectPreset(config)).resolves.toEqual({
code: null,
values: null,
})
})
})

View File

@@ -0,0 +1,773 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { findTailwindColorFamily } from "@/src/colors"
import {
getProjectInfo,
type ProjectInfo,
} from "@/src/utils/get-project-info"
import type { Config } from "@/src/utils/get-config"
import postcss from "postcss"
import { Node, Project, ScriptKind, SyntaxKind } from "ts-morph"
import { DEFAULT_PRESETS } from "./defaults"
import {
encodePreset,
PRESET_BASE_COLORS,
PRESET_FONTS,
PRESET_FONT_HEADINGS,
PRESET_ICON_LIBRARIES,
PRESET_MENU_ACCENTS,
PRESET_MENU_COLORS,
PRESET_THEMES,
type PresetConfig,
} from "./preset"
const PRESET_BASE_COLOR_SET = new Set<string>(PRESET_BASE_COLORS)
const PRESET_ICON_LIBRARY_SET = new Set<string>(PRESET_ICON_LIBRARIES)
const PRESET_MENU_ACCENT_SET = new Set<string>(PRESET_MENU_ACCENTS)
const PRESET_MENU_COLOR_SET = new Set<string>(PRESET_MENU_COLORS)
const PRESET_FONT_SET = new Set<string>(PRESET_FONTS)
const PRESET_FONT_HEADING_SET = new Set<string>(PRESET_FONT_HEADINGS)
const PRESET_THEME_SET = new Set<string>(PRESET_THEMES)
const SERIF_FONTS = new Set<PresetConfig["font"]>([
"lora",
"merriweather",
"playfair-display",
"noto-serif",
"roboto-slab",
])
const MONO_FONTS = new Set<PresetConfig["font"]>([
"jetbrains-mono",
"geist-mono",
])
const ROOT_FONT_VARIABLES = [
"--font-sans",
"--font-serif",
"--font-mono",
] as const
type RootFontVariable = (typeof ROOT_FONT_VARIABLES)[number]
const ROOT_FONT_VARIABLE_SET = new Set<string>(ROOT_FONT_VARIABLES)
const FONT_VARIABLES = [
...ROOT_FONT_VARIABLES,
"--font-heading",
] as const
type FontVariable = (typeof FONT_VARIABLES)[number]
const FONT_VARIABLE_SET = new Set<string>(FONT_VARIABLES)
type CssState = {
darkVars: Record<string, string>
imports: string[]
rootVars: Record<string, string>
themeVars: Record<string, string>
}
type NextFontState = {
appliedBodyVariable: RootFontVariable | null
variables: Partial<Record<FontVariable, PresetConfig["font"]>>
}
const RADIUS_MAP: Record<string, PresetConfig["radius"]> = {
"0": "none",
"0rem": "none",
"0.45rem": "small",
"0.625rem": "default",
"0.875rem": "large",
}
export async function resolveProjectPreset(
config: Config,
projectInfo?: ProjectInfo | null
) {
const style = normalizePresetStyle(config.style)
if (!style) {
return { code: null, values: null }
}
const defaults = DEFAULT_PRESETS[style]
const cssState = await readCssState(config.resolvedPaths.tailwindCss)
let resolvedProjectInfo = projectInfo
if (projectInfo === undefined) {
// Most callers already have project info. This keeps the resolver usable
// in isolation without forcing them to fetch it first.
try {
resolvedProjectInfo = await getProjectInfo(config.resolvedPaths.cwd, {
configCssFile: config.tailwind.css,
})
} catch {
resolvedProjectInfo = null
}
}
const nextFonts = await readNextFontState(config, resolvedProjectInfo)
const font = resolveBodyFont(cssState, nextFonts) ?? defaults.font
const fontHeading = normalizeFontHeading(
resolveHeadingFont(cssState, font, nextFonts) ?? defaults.fontHeading,
font,
defaults.fontHeading
)
const values = {
style,
baseColor: asPresetBaseColor(config.tailwind.baseColor) ?? defaults.baseColor,
theme: matchTheme(cssState) ?? defaults.theme,
chartColor: matchChartColor(cssState) ?? defaults.chartColor,
iconLibrary:
asPresetIconLibrary(config.iconLibrary) ?? defaults.iconLibrary,
font,
fontHeading,
radius: matchRadius(cssState.rootVars["--radius"]) ?? defaults.radius,
menuAccent:
asPresetMenuAccent(config.menuAccent) ?? defaults.menuAccent,
menuColor: asPresetMenuColor(config.menuColor) ?? defaults.menuColor,
} satisfies PresetConfig
return {
code: encodePreset(values),
values,
}
}
async function readCssState(tailwindCssPath?: string) {
const fallbackState: CssState = {
darkVars: {},
imports: [],
rootVars: {},
themeVars: {},
}
if (!tailwindCssPath) {
return fallbackState
}
try {
const input = await fs.readFile(tailwindCssPath, "utf8")
return extractCssState(input)
} catch {
return fallbackState
}
}
function normalizePresetStyle(style: string | undefined) {
if (!style) {
return null
}
const normalized = style.replace(/^(base|radix)-/, "")
if (!(normalized in DEFAULT_PRESETS)) {
return null
}
return normalized as keyof typeof DEFAULT_PRESETS
}
function extractCssState(input: string) {
const root = postcss.parse(input)
const state: CssState = {
darkVars: {},
imports: [],
rootVars: {},
themeVars: {},
}
root.walkAtRules("import", (atRule) => {
const source = parseImportSource(atRule.params)
if (source) {
state.imports.push(source)
}
})
root.walkRules((rule) => {
const selectors = rule.selector
.split(",")
.map((selector) => selector.trim())
.filter(Boolean)
if (selectors.includes(":root")) {
collectDeclarations(rule, state.rootVars)
}
if (selectors.includes(".dark")) {
collectDeclarations(rule, state.darkVars)
}
})
root.walkAtRules("theme", (atRule) => {
if (atRule.params.trim() !== "inline") {
return
}
collectDeclarations(atRule, state.themeVars)
})
return state
}
function collectDeclarations(
node: { nodes?: postcss.ChildNode[] },
target: Record<string, string>
) {
for (const child of node.nodes ?? []) {
if (child.type !== "decl" || !child.prop.startsWith("--")) {
continue
}
target[child.prop] = child.value.trim()
}
}
function parseImportSource(params: string) {
const normalized = params.trim()
const match =
normalized.match(/^url\((['"]?)(.+?)\1\)$/) ??
normalized.match(/^(['"])(.+?)\1$/)
return match?.[2] ?? null
}
function matchTheme(state: CssState) {
const lightTheme = matchPresetThemeValue(state.rootVars["--primary"])
if (!lightTheme) {
return null
}
const darkPrimary = state.darkVars["--primary"]
if (!darkPrimary) {
return lightTheme
}
const darkTheme = matchPresetThemeValue(darkPrimary)
return darkTheme === lightTheme ? lightTheme : null
}
function matchChartColor(state: CssState) {
const lightChartColor = matchPresetThemeValue(state.rootVars["--chart-1"])
if (!lightChartColor) {
return null
}
const darkChartColorValue = state.darkVars["--chart-1"]
if (!darkChartColorValue) {
return lightChartColor
}
const darkChartColor = matchPresetThemeValue(darkChartColorValue)
return darkChartColor === lightChartColor ? lightChartColor : null
}
function matchPresetThemeValue(value: string | undefined) {
const family = findTailwindColorFamily(value)
if (!family || !PRESET_THEME_SET.has(family)) {
return null
}
return family as PresetConfig["theme"]
}
function matchRadius(value: string | undefined) {
if (!value) {
return null
}
const normalized = normalizeCssValue(value)
return RADIUS_MAP[normalized] ?? null
}
function resolveBodyFont(state: CssState, nextFonts: NextFontState) {
for (const variable of ROOT_FONT_VARIABLES) {
const matched = matchFontFromVariable(state, variable)
if (matched) {
return matched
}
}
for (const variable of ROOT_FONT_VARIABLES) {
const imported = matchFontByImports(state.imports, variable)
if (imported) {
return imported
}
}
return matchNextBodyFont(nextFonts)
}
function resolveHeadingFont(
state: CssState,
bodyFont: PresetConfig["font"],
nextFonts: NextFontState
) {
const resolved = resolveFontValue(state, "--font-heading")
const matched = resolved ? parseFontFromFamily(resolved) : null
if (matched) {
return matched === bodyFont ? "inherit" : matched
}
const nextHeadingFont = nextFonts.variables["--font-heading"]
const value = getCssVariableValue(state, "--font-heading")
if (!value) {
return nextHeadingFont && nextHeadingFont !== bodyFont
? nextHeadingFont
: null
}
const reference = getVarReference(value)
if (reference && ROOT_FONT_VARIABLE_SET.has(reference)) {
const rootFont = matchFontFromVariable(
state,
reference as RootFontVariable
)
const nextRootFont = nextFonts.variables[reference as RootFontVariable]
const resolvedRootFont = rootFont ?? nextRootFont ?? null
if (!resolvedRootFont || resolvedRootFont === bodyFont) {
return "inherit"
}
return resolvedRootFont
}
if (reference === "--font-heading") {
if (!nextHeadingFont || nextHeadingFont === bodyFont) {
return "inherit"
}
return nextHeadingFont
}
return nextHeadingFont && nextHeadingFont !== bodyFont
? nextHeadingFont
: null
}
function normalizeFontHeading(
fontHeading: PresetConfig["fontHeading"],
bodyFont: PresetConfig["font"],
fallback: PresetConfig["fontHeading"]
) {
const normalized = fontHeading === bodyFont ? "inherit" : fontHeading
return PRESET_FONT_HEADING_SET.has(normalized) ? normalized : fallback
}
function resolveFontValue(
state: CssState,
variable: FontVariable,
seen = new Set<string>()
) {
if (seen.has(variable)) {
return null
}
seen.add(variable)
const value = getCssVariableValue(state, variable)
if (!value) {
return null
}
const reference = getVarReference(value)
if (!reference) {
return value
}
if (FONT_VARIABLE_SET.has(reference)) {
return resolveFontValue(state, reference as FontVariable, seen)
}
return null
}
function getCssVariableValue(state: CssState, variable: FontVariable) {
return state.themeVars[variable] ?? state.rootVars[variable] ?? null
}
function getVarReference(value: string) {
const normalized = normalizeCssValue(value)
const match = normalized.match(/^var\((--[a-z0-9-]+)\)$/)
return match?.[1] ?? null
}
function matchFontFromVariable(state: CssState, variable: RootFontVariable) {
const resolved = resolveFontValue(state, variable)
const matched = resolved ? parseFontFromFamily(resolved) : null
if (matched) {
return matched
}
return null
}
function matchFontByImports(imports: string[], variable: RootFontVariable) {
const matches = imports.flatMap((input) => {
const font = parseFontFromDependency(input)
return font && getFontVariable(font) === variable ? [font] : []
})
return matches.length === 1 ? matches[0] : null
}
function matchNextBodyFont(nextFonts: NextFontState) {
if (
nextFonts.appliedBodyVariable &&
nextFonts.variables[nextFonts.appliedBodyVariable]
) {
return nextFonts.variables[nextFonts.appliedBodyVariable] ?? null
}
const matches = ROOT_FONT_VARIABLES.map(
(variable) => nextFonts.variables[variable]
)
.filter(Boolean)
.filter((font, index, allFonts) => allFonts.indexOf(font) === index) as
PresetConfig["font"][]
return matches.length === 1 ? matches[0] : null
}
async function readNextFontState(
config: Config,
projectInfo: ProjectInfo | null | undefined
) {
const fallbackState: NextFontState = {
appliedBodyVariable: null,
variables: {},
}
if (
!projectInfo ||
(projectInfo.framework.name !== "next-app" &&
projectInfo.framework.name !== "next-pages")
) {
return fallbackState
}
const sourcePath = findNextFontSourceFile(config, projectInfo)
if (!sourcePath) {
return fallbackState
}
try {
const input = await fs.readFile(sourcePath, "utf8")
return extractNextFontState(input, projectInfo.framework.name)
} catch {
return fallbackState
}
}
function findNextFontSourceFile(
config: Config,
projectInfo: ProjectInfo
) {
const ext = projectInfo.isTsx ? "tsx" : "jsx"
const candidates =
projectInfo.framework.name === "next-app"
? projectInfo.isSrcDir
? [`src/app/layout.${ext}`, `app/layout.${ext}`]
: [`app/layout.${ext}`]
: projectInfo.isSrcDir
? [`src/pages/_app.${ext}`, `pages/_app.${ext}`]
: [`pages/_app.${ext}`]
for (const relativePath of candidates) {
const fullPath = path.join(config.resolvedPaths.cwd, relativePath)
if (existsSync(fullPath)) {
return fullPath
}
}
return null
}
function extractNextFontState(
input: string,
framework: ProjectInfo["framework"]["name"]
) {
const project = new Project({
compilerOptions: {},
})
const sourceFile = project.createSourceFile("font-source.tsx", input, {
overwrite: true,
scriptKind: ScriptKind.TSX,
})
const importedFonts = new Map<string, PresetConfig["font"]>()
for (const declaration of sourceFile.getImportDeclarations()) {
if (declaration.getModuleSpecifierValue() !== "next/font/google") {
continue
}
for (const namedImport of declaration.getNamedImports()) {
const importedName = namedImport.getName()
const localName = namedImport.getAliasNode()?.getText() ?? importedName
const font = parseFontFromNextImport(importedName)
if (font) {
importedFonts.set(localName, font)
}
}
}
const variables: NextFontState["variables"] = {}
const declarations = new Map<string, FontVariable>()
for (const statement of sourceFile.getVariableStatements()) {
for (const declaration of statement.getDeclarations()) {
const initializer = declaration.getInitializer()
if (!initializer?.isKind(SyntaxKind.CallExpression)) {
continue
}
const font = importedFonts.get(initializer.getExpression().getText())
if (!font) {
continue
}
const variable = getNextFontVariable(initializer)
if (!variable) {
continue
}
declarations.set(declaration.getName(), variable)
variables[variable] = font
}
}
return {
appliedBodyVariable: getAppliedBodyVariable(sourceFile, declarations, framework),
variables,
}
}
function getNextFontVariable(
callExpression: Node & { getArguments(): Node[] }
) {
const firstArg = callExpression.getArguments()[0]
if (!firstArg || !Node.isObjectLiteralExpression(firstArg)) {
return null
}
const property = firstArg.getProperty("variable")
if (!property || !Node.isPropertyAssignment(property)) {
return null
}
const initializer = property.getInitializer()
if (!initializer) {
return null
}
const variable = stripQuotes(initializer.getText())
if (!FONT_VARIABLE_SET.has(variable)) {
return null
}
return variable as FontVariable
}
function getAppliedBodyVariable(
sourceFile: ReturnType<Project["createSourceFile"]>,
declarations: Map<string, FontVariable>,
framework: ProjectInfo["framework"]["name"]
) {
const elements = sourceFile
.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)
.filter((element) =>
framework === "next-app"
? element.getTagNameNode().getText() === "html"
: true
)
const discovered = new Set<RootFontVariable>()
for (const element of elements) {
const className = element.getAttribute("className")
if (!className || !Node.isJsxAttribute(className)) {
continue
}
const initializer = className.getInitializer()
if (!initializer) {
continue
}
const appliedVariables = getAppliedFontVariables(initializer, declarations)
const utilityVariable = getAppliedBodyUtilityVariable(initializer)
if (
utilityVariable &&
appliedVariables.includes(utilityVariable)
) {
return utilityVariable
}
if (appliedVariables.length === 1) {
discovered.add(appliedVariables[0])
}
}
return discovered.size === 1 ? Array.from(discovered)[0] : null
}
function getAppliedFontVariables(
initializer: Node,
declarations: Map<string, FontVariable>
) {
const expressions = Node.isJsxExpression(initializer)
? [
initializer.getExpression(),
...initializer.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression),
].filter(Boolean)
: []
const variables = new Set<RootFontVariable>()
for (const expression of expressions) {
if (!expression || !Node.isPropertyAccessExpression(expression)) {
continue
}
if (expression.getName() !== "variable") {
continue
}
const target = expression.getExpression().getText()
const variable = declarations.get(target)
if (variable && ROOT_FONT_VARIABLE_SET.has(variable)) {
variables.add(variable as RootFontVariable)
}
}
return Array.from(variables)
}
function getAppliedBodyUtilityVariable(initializer: Node) {
const text = getStringContent(initializer)
if (/\bfont-sans\b/.test(text)) {
return "--font-sans" as const
}
if (/\bfont-serif\b/.test(text)) {
return "--font-serif" as const
}
if (/\bfont-mono\b/.test(text)) {
return "--font-mono" as const
}
return null
}
function getStringContent(node: Node) {
const fragments: string[] = []
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
fragments.push(node.getLiteralValue())
}
for (const literal of node.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
fragments.push(literal.getLiteralValue())
}
for (const literal of node.getDescendantsOfKind(
SyntaxKind.NoSubstitutionTemplateLiteral
)) {
fragments.push(literal.getLiteralValue())
}
return fragments.length > 0 ? fragments.join(" ") : node.getText()
}
function stripQuotes(value: string) {
return value.replace(/^['"]|['"]$/g, "")
}
function parseFontFromFamily(value: string | undefined) {
if (!value) {
return null
}
const primaryFamily = stripQuotes(value.split(",")[0]?.trim() ?? "")
.replace(/\s+variable$/i, "")
.trim()
if (!primaryFamily) {
return null
}
return toPresetFont(primaryFamily.replace(/\s+/g, "-"))
}
function parseFontFromDependency(value: string | undefined) {
if (!value) {
return null
}
const normalized = normalizeCssValue(value)
// Preset font imports use variable fontsource packages.
const prefix = "@fontsource-variable/"
if (!normalized.startsWith(prefix)) {
return null
}
return toPresetFont(normalized.slice(prefix.length))
}
function parseFontFromNextImport(value: string | undefined) {
if (!value) {
return null
}
return toPresetFont(value.replace(/_/g, "-"))
}
function toPresetFont(value: string | undefined) {
const normalized = normalizeCssValue(value)
return PRESET_FONT_SET.has(normalized)
? (normalized as PresetConfig["font"])
: null
}
function getFontVariable(font: PresetConfig["font"]) {
if (MONO_FONTS.has(font)) {
return "--font-mono"
}
if (SERIF_FONTS.has(font)) {
return "--font-serif"
}
return "--font-sans"
}
function normalizeCssValue(value: string | undefined) {
if (!value) {
return ""
}
return value
.trim()
.replace(/\s+/g, " ")
.replace(/\s*,\s*/g, ", ")
.replace(/"/g, "'")
.toLowerCase()
}
function asPresetBaseColor(value: string | undefined) {
return PRESET_BASE_COLOR_SET.has(value ?? "")
? (value as PresetConfig["baseColor"])
: null
}
function asPresetIconLibrary(value: string | undefined) {
return PRESET_ICON_LIBRARY_SET.has(value ?? "")
? (value as PresetConfig["iconLibrary"])
: null
}
function asPresetMenuAccent(value: string | undefined) {
return PRESET_MENU_ACCENT_SET.has(value ?? "")
? (value as PresetConfig["menuAccent"])
: null
}
function asPresetMenuColor(value: string | undefined) {
return PRESET_MENU_COLOR_SET.has(value ?? "")
? (value as PresetConfig["menuColor"])
: null
}