diff --git a/.changeset/shaggy-hands-hope.md b/.changeset/shaggy-hands-hope.md new file mode 100644 index 0000000000..3344488e2a --- /dev/null +++ b/.changeset/shaggy-hands-hope.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add preset info to npx shadcn info diff --git a/packages/shadcn/src/colors.ts b/packages/shadcn/src/colors.ts new file mode 100644 index 0000000000..c3742e9534 --- /dev/null +++ b/packages/shadcn/src/colors.ts @@ -0,0 +1,470 @@ +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] + +export const TAILWIND_COLORS: Record< + TailwindColorFamily, + Record +> = { + 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)", + }, +} + +// Old preset codes only carried one theme value, so these legacy chart HSLs +// should resolve to a neutral fallback family instead of a separate theme. +const LEGACY_COLOR_FAMILY_ALIASES: Record = { + "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() + +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() +} diff --git a/packages/shadcn/src/commands/info.ts b/packages/shadcn/src/commands/info.ts index ab5afddd31..826298ea04 100644 --- a/packages/shadcn/src/commands/info.ts +++ b/packages/shadcn/src/commands/info.ts @@ -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,14 @@ function getRegistries( return result } -function collectInfo( +export async function collectInfo( projectInfo: ProjectInfo | null, config: Awaited>, components: string[], base: string ) { + const preset = config ? await resolveProjectPreset(config, projectInfo) : null + return { project: projectInfo ? { @@ -142,6 +145,7 @@ function collectInfo( registries: getRegistries(config.registries), } : null, + preset, components, links: { docs: `${SHADCN_URL}/docs`, @@ -153,7 +157,7 @@ function collectInfo( } } -function printInfo(data: ReturnType) { +export function printInfo(data: Awaited>) { // Project. logger.log(highlighter.info("Project")) if (data.project) { @@ -187,6 +191,60 @@ function printInfo(data: ReturnType) { menuAccent: data.config.menuAccent ?? "-", }) + logger.break() + logger.log(highlighter.info("Preset")) + if (!data.preset?.code) { + printEntries({ + "--preset": "-", + }) + } else { + const fallbacks = data.preset.fallbacks ?? [] + const formatPresetValue = (key: string, value: string | undefined) => { + const suffix = fallbacks.includes(key) ? "*" : "" + return `${value ?? "-"}${suffix}` + } + + printEntries({ + "--preset": data.preset.code, + url: `${SHADCN_URL}/create?preset=${data.preset.code}`, + style: data.preset.values?.style ?? "-", + baseColor: formatPresetValue( + "baseColor", + data.preset.values?.baseColor + ), + theme: formatPresetValue("theme", data.preset.values?.theme), + chartColor: formatPresetValue( + "chartColor", + data.preset.values?.chartColor + ), + iconLibrary: formatPresetValue( + "iconLibrary", + data.preset.values?.iconLibrary + ), + font: formatPresetValue("font", data.preset.values?.font), + fontHeading: formatPresetValue( + "fontHeading", + data.preset.values?.fontHeading + ), + radius: formatPresetValue("radius", data.preset.values?.radius), + menuAccent: formatPresetValue( + "menuAccent", + data.preset.values?.menuAccent + ), + menuColor: formatPresetValue( + "menuColor", + data.preset.values?.menuColor + ), + }) + + if (fallbacks.length > 0) { + logger.log("") + logger.log( + " * Uses preset defaults for values not available as options on shadcn/create." + ) + } + } + // Aliases. logger.break() logger.log(highlighter.info("Aliases")) diff --git a/packages/shadcn/src/preset/defaults.ts b/packages/shadcn/src/preset/defaults.ts new file mode 100644 index 0000000000..5e0ce9d83b --- /dev/null +++ b/packages/shadcn/src/preset/defaults.ts @@ -0,0 +1,116 @@ +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, + }, + sera: { + title: "Sera", + description: "Lucide / Noto Sans + Playfair Display", + style: "sera", + baseColor: "taupe", + theme: "taupe", + chartColor: "taupe", + iconLibrary: "lucide", + font: "noto-sans", + fontHeading: "playfair-display", + menuAccent: "subtle", + menuColor: "default", + radius: "default", + rtl: false, + }, +} satisfies Record< + PresetConfig["style"], + PresetConfig & { + description: string + rtl: boolean + title: string + } +> diff --git a/packages/shadcn/src/preset/presets.test.ts b/packages/shadcn/src/preset/presets.test.ts index 937e6558be..397c8c4893 100644 --- a/packages/shadcn/src/preset/presets.test.ts +++ b/packages/shadcn/src/preset/presets.test.ts @@ -84,6 +84,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 include chartColor from default presets", () => { const url = resolveInitUrl({ ...DEFAULT_PRESETS.sera, base: "base" }) const parsed = new URL(url) diff --git a/packages/shadcn/src/preset/presets.ts b/packages/shadcn/src/preset/presets.ts index 55ef8ddafa..7139a9f9f8 100644 --- a/packages/shadcn/src/preset/presets.ts +++ b/packages/shadcn/src/preset/presets.ts @@ -12,120 +12,9 @@ import open from "open" import prompts from "prompts" import { type z } from "zod" -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" as const, - menuColor: "default" as const, +import { DEFAULT_PRESETS } from "./defaults" - 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" as const, - menuColor: "default" as const, - - 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" as const, - menuColor: "default" as const, - - 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" as const, - menuColor: "default" as const, - - 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" as const, - menuColor: "default" as const, - - 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" as const, - menuColor: "default" as const, - - radius: "default", - rtl: false, - }, - sera: { - title: "Sera", - description: "Lucide / Noto Sans + Playfair Display", - style: "sera", - baseColor: "taupe", - theme: "taupe", - chartColor: "taupe", - iconLibrary: "lucide", - font: "noto-sans", - fontHeading: "playfair-display", - menuAccent: "subtle" as const, - menuColor: "default" as const, - - radius: "default", - rtl: false, - }, -} +export { DEFAULT_PRESETS } from "./defaults" export function resolveCreateUrl( searchParams?: Partial<{ @@ -220,7 +109,7 @@ export function resolveInitUrl( radius: preset.radius, }) - if (preset.chartColor) { + if (preset.chartColor && preset.chartColor !== "neutral") { params.set("chartColor", preset.chartColor) } diff --git a/packages/shadcn/src/preset/resolve.test.ts b/packages/shadcn/src/preset/resolve.test.ts new file mode 100644 index 0000000000..76784de768 --- /dev/null +++ b/packages/shadcn/src/preset/resolve.test.ts @@ -0,0 +1,337 @@ +import { promises as fs } from "fs" +import os from "os" +import path from "path" +import { FRAMEWORKS } from "@/src/utils/frameworks" +import { createConfig } from "@/src/utils/get-config" +import { afterEach, describe, expect, it } from "vitest" + +import { encodePreset, type PresetConfig } from "./preset" +import { resolveProjectPreset } from "./resolve" + +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 +}) { + 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!)) + expect(result.fallbacks).toEqual([]) + }) + + it("resolves self-referential theme font vars from root vars", async () => { + const config = await createTestConfig({ + css: `:root { + --radius: 0.625rem; + --primary: oklch(0.205 0 0); + --chart-1: oklch(0.87 0 0); + --font-sans: "Lora Variable", serif; +} + +.dark { + --primary: oklch(0.205 0 0); + --chart-1: oklch(0.87 0 0); +} + +@theme inline { + --font-sans: var(--font-sans); +}`, + }) + + const result = await resolveProjectPreset(config) + + expect(result.values).toMatchObject({ + font: "lora", + }) + expect(result.fallbacks).not.toContain("font") + }) + + it("matches serif font imports against the serif root variable", async () => { + const config = await createTestConfig({ + style: "base-nova", + css: `@import "@fontsource-variable/inter"; +@import "@fontsource-variable/eb-garamond"; + +: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); +}`, + }) + + const result = await resolveProjectPreset(config) + + expect(result.values).toMatchObject({ + font: "inter", + }) + expect(result.fallbacks).not.toContain("font") + }) + + 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!)) + expect(result.fallbacks).toEqual([ + "theme", + "chartColor", + "font", + "fontHeading", + "radius", + ]) + }) + + 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 ( + + {children} + + ) +}`, + }, + }) + + 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
+}`, + }, + }) + + 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 an empty preset 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, + fallbacks: [], + values: null, + }) + }) +}) diff --git a/packages/shadcn/src/preset/resolve.ts b/packages/shadcn/src/preset/resolve.ts new file mode 100644 index 0000000000..849fc15e4a --- /dev/null +++ b/packages/shadcn/src/preset/resolve.ts @@ -0,0 +1,811 @@ +import { existsSync, promises as fs } from "fs" +import path from "path" +import { findTailwindColorFamily } from "@/src/colors" +import type { Config } from "@/src/utils/get-config" +import { getProjectInfo, type ProjectInfo } from "@/src/utils/get-project-info" +import postcss from "postcss" +import { Node, Project, ScriptKind, SyntaxKind } from "ts-morph" + +import { DEFAULT_PRESETS } from "./defaults" +import { + encodePreset, + PRESET_BASE_COLORS, + PRESET_FONT_HEADINGS, + PRESET_FONTS, + PRESET_ICON_LIBRARIES, + PRESET_MENU_ACCENTS, + PRESET_MENU_COLORS, + PRESET_THEMES, + type PresetConfig, +} from "./preset" + +const PRESET_BASE_COLOR_SET = new Set(PRESET_BASE_COLORS) +const PRESET_ICON_LIBRARY_SET = new Set(PRESET_ICON_LIBRARIES) +const PRESET_MENU_ACCENT_SET = new Set(PRESET_MENU_ACCENTS) +const PRESET_MENU_COLOR_SET = new Set(PRESET_MENU_COLORS) +const PRESET_FONT_SET = new Set(PRESET_FONTS) +const PRESET_FONT_HEADING_SET = new Set(PRESET_FONT_HEADINGS) +const PRESET_THEME_SET = new Set(PRESET_THEMES) +const SERIF_FONTS = new Set([ + "eb-garamond", + "instrument-serif", + "lora", + "merriweather", + "playfair-display", + "noto-serif", + "roboto-slab", +]) +const MONO_FONTS = new Set([ + "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(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(FONT_VARIABLES) +type CssState = { + darkVars: Record + imports: string[] + rootVars: Record + themeVars: Record +} +type NextFontState = { + appliedBodyVariable: RootFontVariable | null + variables: Partial> +} +const EMPTY_NEXT_FONT_STATE: NextFontState = { + appliedBodyVariable: null, + variables: {}, +} +const RADIUS_MAP: Record = { + "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, fallbacks: [], values: null } + } + + const defaults = DEFAULT_PRESETS[style] + const cssState = await readCssState(config.resolvedPaths.tailwindCss) + + const baseColor = asPresetValue( + PRESET_BASE_COLOR_SET, + config.tailwind.baseColor + ) + const theme = matchTheme(cssState) + const chartColor = matchChartColor(cssState) + const iconLibrary = asPresetValue( + PRESET_ICON_LIBRARY_SET, + config.iconLibrary + ) + let resolvedFont: PresetConfig["font"] | null = resolveBodyFont( + cssState, + EMPTY_NEXT_FONT_STATE + ) + let font = resolvedFont ?? defaults.font + let resolvedFontHeading: PresetConfig["fontHeading"] | null = + resolveHeadingFont(cssState, font, EMPTY_NEXT_FONT_STATE) + + if (!resolvedFont || !resolvedFontHeading) { + 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) + resolvedFont ??= resolveBodyFont(cssState, nextFonts) + font = resolvedFont ?? defaults.font + resolvedFontHeading ??= resolveHeadingFont(cssState, font, nextFonts) + } + + const fontHeading = normalizeFontHeading( + resolvedFontHeading ?? defaults.fontHeading, + font, + defaults.fontHeading + ) + const radius = matchRadius(cssState.rootVars["--radius"]) + const menuAccent = asPresetValue( + PRESET_MENU_ACCENT_SET, + config.menuAccent + ) + const menuColor = asPresetValue( + PRESET_MENU_COLOR_SET, + config.menuColor + ) + + const values = { + style, + baseColor: baseColor ?? defaults.baseColor, + theme: theme ?? defaults.theme, + chartColor: chartColor ?? defaults.chartColor, + iconLibrary: iconLibrary ?? defaults.iconLibrary, + font, + fontHeading, + radius: radius ?? defaults.radius, + menuAccent: menuAccent ?? defaults.menuAccent, + menuColor: menuColor ?? defaults.menuColor, + } satisfies PresetConfig + + const fallbacks = [ + !baseColor && "baseColor", + !theme && "theme", + !chartColor && "chartColor", + !iconLibrary && "iconLibrary", + !resolvedFont && "font", + !resolvedFontHeading && "fontHeading", + !radius && "radius", + !menuAccent && "menuAccent", + !menuColor && "menuColor", + ].filter(Boolean) + + return { + code: encodePreset(values), + fallbacks, + 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 +) { + 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) { + return null + } + + if (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() +) { + 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) { + const themeValue = state.themeVars[variable] + if (themeValue && getVarReference(themeValue) !== variable) { + return themeValue + } + + return state.rootVars[variable] ?? themeValue ?? 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() + 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() + + 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, + declarations: Map, + framework: ProjectInfo["framework"]["name"] +) { + const elements = sourceFile + .getDescendantsOfKind(SyntaxKind.JsxOpeningElement) + .filter((element) => + framework === "next-app" + ? element.getTagNameNode().getText() === "html" + : true + ) + + const discovered = new Set() + + 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 +) { + const expressions = Node.isJsxExpression(initializer) + ? [ + initializer.getExpression(), + ...initializer.getDescendantsOfKind( + SyntaxKind.PropertyAccessExpression + ), + ].filter(Boolean) + : [] + + const variables = new Set() + + 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()) + } + + // Last-resort heuristic for dynamic className expressions. + 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 asPresetValue( + set: Set, + value: string | undefined +) { + return value && set.has(value) ? (value as T) : null +}