stera-ui 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -153,7 +153,7 @@ async function writeComponentFiles(items, config, projectRoot, options = {}) {
153
153
  const written = [];
154
154
  const skipped = [];
155
155
  for (const item of items) {
156
- for (const file of item.files) {
156
+ for (const file of item.files ?? []) {
157
157
  const outputPath = resolveOutputPath(config, projectRoot, file);
158
158
  const relativePath = path3.relative(projectRoot, outputPath);
159
159
  if (fs2.existsSync(outputPath)) {
@@ -401,7 +401,7 @@ async function add(components, options) {
401
401
  }
402
402
  const resolved = await resolveDependencies(components);
403
403
  for (const item of resolved) {
404
- for (const file of item.files) {
404
+ for (const file of item.files ?? []) {
405
405
  if (file.content) {
406
406
  file.content = applyTransforms(
407
407
  file.content,
@@ -451,9 +451,9 @@ async function add(components, options) {
451
451
  }
452
452
 
453
453
  // src/commands/init.ts
454
- import fs11 from "fs";
455
- import path12 from "path";
456
- import { select, confirm } from "@inquirer/prompts";
454
+ import fs9 from "fs";
455
+ import path10 from "path";
456
+ import { select } from "@inquirer/prompts";
457
457
 
458
458
  // src/utils/detect-project.ts
459
459
  import fs5 from "fs";
@@ -626,243 +626,324 @@ function patchCssVariables(filePath, patches) {
626
626
  }
627
627
  return patched;
628
628
  }
629
- function removeCssImport(filePath, importPath) {
630
- const content = fs7.readFileSync(filePath, "utf-8");
631
- const pattern = new RegExp(
632
- `@import\\s+['"]${escapeRegex(importPath)}['"]\\s*;?\\n?`
633
- );
634
- if (!pattern.test(content)) return false;
635
- fs7.writeFileSync(filePath, content.replace(pattern, ""), "utf-8");
636
- return true;
637
- }
638
629
  function escapeRegex(str) {
639
630
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
640
631
  }
641
632
 
642
- // src/utils/setup-fonts-generic.ts
643
- import fs9 from "fs";
644
- import path10 from "path";
645
-
646
- // src/utils/copy-fonts.ts
647
- import fs8 from "fs";
633
+ // src/utils/update-fonts.ts
634
+ import { existsSync, promises as fs8 } from "fs";
648
635
  import path9 from "path";
649
- import { createRequire } from "module";
650
- var FONT_FILES = [
651
- {
652
- src: "geist-sans/Geist-Variable.woff2",
653
- dest: "geist/Geist-Variable.woff2"
654
- },
655
- {
656
- src: "geist-sans/Geist-Italic[wght].woff2",
657
- dest: "geist/Geist-Italic.woff2"
658
- },
659
- {
660
- src: "geist-mono/GeistMono-Variable.woff2",
661
- dest: "geist-mono/GeistMono-Variable.woff2"
662
- },
663
- {
664
- src: "geist-mono/GeistMono-Italic[wght].woff2",
665
- dest: "geist-mono/GeistMono-Italic.woff2"
666
- }
667
- ];
668
- function resolveGeistFontsDir(cwd) {
669
- const require2 = createRequire(path9.join(cwd, "package.json"));
670
- const geistEntry = require2.resolve("geist/font/sans");
671
- const distDir = path9.dirname(geistEntry);
672
- return path9.join(distDir, "fonts");
636
+ import {
637
+ Project,
638
+ ScriptKind,
639
+ SyntaxKind,
640
+ VariableDeclarationKind
641
+ } from "ts-morph";
642
+ var ROOT_FONT_VARIABLES = /* @__PURE__ */ new Set([
643
+ "--font-sans",
644
+ "--font-serif",
645
+ "--font-mono"
646
+ ]);
647
+ function massageTreeForFonts(fonts, project) {
648
+ const result = {
649
+ cssVars: {},
650
+ dependencies: [],
651
+ cssImports: []
652
+ };
653
+ if (fonts.length === 0) return result;
654
+ const isNext = project.framework === "next";
655
+ for (const font of fonts) {
656
+ if (isNext) {
657
+ result.cssVars[font.font.variable] = `var(${font.font.variable})`;
658
+ } else {
659
+ const fontName = font.name.replace(/^font-/, "");
660
+ const fontSourceDep = font.font.dependency ?? `@fontsource-variable/${fontName}`;
661
+ result.dependencies.push(fontSourceDep);
662
+ result.cssImports.push(`@import "${fontSourceDep}";`);
663
+ result.cssVars[font.font.variable] = font.font.family;
664
+ }
665
+ }
666
+ return result;
673
667
  }
674
- function copyFonts(cwd) {
675
- const spinner = createSpinner("Copying font files");
676
- const fontsDir = resolveGeistFontsDir(cwd);
677
- const publicFontsDir = path9.join(cwd, "public", "fonts");
678
- const written = [];
679
- for (const file of FONT_FILES) {
680
- const srcPath = path9.join(fontsDir, file.src);
681
- const destPath = path9.join(publicFontsDir, file.dest);
682
- if (fs8.existsSync(destPath)) continue;
683
- if (!fs8.existsSync(srcPath)) {
684
- spinner.fail(`Font file not found: ${file.src}`);
685
- throw new Error(
686
- `Could not find ${srcPath}. Is the geist package installed?`
668
+ async function updateFonts(fonts, project, cwd, options = {}) {
669
+ if (fonts.length === 0) return;
670
+ if (project.framework !== "next") return;
671
+ const layoutPath = findLayoutFile(project, cwd);
672
+ if (!layoutPath) return;
673
+ if (options.silent) {
674
+ try {
675
+ const layoutContent = await fs8.readFile(layoutPath, "utf-8");
676
+ const updatedContent = transformLayoutFonts(layoutContent, fonts);
677
+ if (updatedContent !== layoutContent) {
678
+ await fs8.writeFile(layoutPath, updatedContent, "utf-8");
679
+ }
680
+ } catch {
681
+ }
682
+ return;
683
+ }
684
+ const spinner = createSpinner("Updating fonts");
685
+ try {
686
+ const layoutContent = await fs8.readFile(layoutPath, "utf-8");
687
+ const updatedContent = transformLayoutFonts(layoutContent, fonts);
688
+ if (updatedContent !== layoutContent) {
689
+ await fs8.writeFile(layoutPath, updatedContent, "utf-8");
690
+ }
691
+ spinner.succeed("Updated layout with font imports");
692
+ } catch {
693
+ spinner.fail("Failed to update fonts");
694
+ }
695
+ }
696
+ function findLayoutFile(project, cwd) {
697
+ if (!project.layoutFile) return null;
698
+ const fullPath = path9.join(cwd, project.layoutFile);
699
+ return existsSync(fullPath) ? fullPath : null;
700
+ }
701
+ function transformLayoutFonts(input, fonts) {
702
+ const project = new Project({ compilerOptions: {} });
703
+ const sourceFile = project.createSourceFile("layout.tsx", input, {
704
+ scriptKind: ScriptKind.TSX
705
+ });
706
+ const googleFonts = fonts.filter((f) => f.font.provider === "google");
707
+ const fontVariableNames = [];
708
+ const fontUtilityClasses = [];
709
+ for (const font of googleFonts) {
710
+ const importName = font.font.import;
711
+ if (!importName) continue;
712
+ const existingImport = sourceFile.getImportDeclaration(
713
+ (decl) => decl.getModuleSpecifierValue() === "next/font/google"
714
+ );
715
+ let hasExistingImport = false;
716
+ if (existingImport) {
717
+ const namedImports = existingImport.getNamedImports();
718
+ hasExistingImport = namedImports.some(
719
+ (imp) => imp.getName() === importName
687
720
  );
721
+ if (!hasExistingImport) {
722
+ existingImport.addNamedImport(importName);
723
+ }
724
+ } else {
725
+ sourceFile.addImportDeclaration({
726
+ moduleSpecifier: "next/font/google",
727
+ namedImports: [importName]
728
+ });
729
+ }
730
+ const varName = getFontVariableName(importName, font.font.variable);
731
+ const fontOptions = buildFontOptions(font);
732
+ const existingVarDecl = findFontVariableDeclaration(
733
+ sourceFile,
734
+ font.font.variable
735
+ );
736
+ if (hasExistingImport && !existingVarDecl && isRootFontVariable(font.font.variable)) {
737
+ continue;
738
+ }
739
+ if (existingVarDecl) {
740
+ existingVarDecl.setInitializer(`${importName}(${fontOptions})`);
741
+ if (existingVarDecl.getName() !== varName) {
742
+ existingVarDecl.rename(varName);
743
+ }
744
+ } else {
745
+ const insertPosition = findInsertPosition(sourceFile);
746
+ const statement = sourceFile.insertVariableStatement(insertPosition, {
747
+ declarationKind: VariableDeclarationKind.Const,
748
+ declarations: [
749
+ {
750
+ name: varName,
751
+ initializer: `${importName}(${fontOptions})`
752
+ }
753
+ ]
754
+ });
755
+ statement.appendWhitespace("\n");
756
+ }
757
+ fontVariableNames.push(varName);
758
+ if (shouldApplyFontUtilityToHtml(font)) {
759
+ fontUtilityClasses.push(font.font.variable.replace("--", ""));
688
760
  }
689
- fs8.mkdirSync(path9.dirname(destPath), { recursive: true });
690
- fs8.copyFileSync(srcPath, destPath);
691
- written.push(path9.relative(cwd, destPath));
692
761
  }
693
- if (written.length > 0) {
694
- spinner.succeed(`Copied ${written.length} font files to public/fonts/`);
695
- } else {
696
- spinner.succeed("Font files already exist");
762
+ const fontFamilyClasses = /* @__PURE__ */ new Set(["font-sans", "font-serif", "font-mono"]);
763
+ const lastFontFamilyClass = [...fontUtilityClasses].reverse().find((cls) => fontFamilyClasses.has(cls));
764
+ const filteredUtilityClasses = fontUtilityClasses.filter(
765
+ (cls) => !fontFamilyClasses.has(cls)
766
+ );
767
+ if (lastFontFamilyClass) {
768
+ filteredUtilityClasses.unshift(lastFontFamilyClass);
697
769
  }
698
- return written;
770
+ if (fontVariableNames.length > 0) {
771
+ updateHtmlClassName(sourceFile, fontVariableNames, filteredUtilityClasses);
772
+ }
773
+ return sourceFile.getFullText();
699
774
  }
700
-
701
- // src/utils/setup-fonts-generic.ts
702
- var FONTS_CSS_CONTENT = `/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
703
- Stera UI font registry
704
- To add a font:
705
- 1. Drop .woff2 file(s) into public/fonts/{family-name}/
706
- 2. Add an @font-face block below
707
- 3. Add a --font-* variable in globals.css
708
- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
709
-
710
- /* \u2500\u2500\u2500 Geist Sans \u2500\u2500\u2500 */
711
- @font-face {
712
- font-family: 'Geist';
713
- src: url('/fonts/geist/Geist-Variable.woff2') format('woff2');
714
- font-weight: 100 900;
715
- font-style: normal;
716
- font-display: swap;
775
+ function buildFontOptions(font) {
776
+ const options = {};
777
+ if (font.font.subsets?.length) options.subsets = font.font.subsets;
778
+ if (font.font.weight?.length) options.weight = font.font.weight;
779
+ options.variable = font.font.variable;
780
+ return JSON.stringify(options).replace(/"([^"]+)":/g, "$1:").replace(/"/g, "'");
717
781
  }
718
-
719
- @font-face {
720
- font-family: 'Geist';
721
- src: url('/fonts/geist/Geist-Italic.woff2') format('woff2');
722
- font-weight: 100 900;
723
- font-style: italic;
724
- font-display: swap;
782
+ function isRootFontVariable(variable) {
783
+ return ROOT_FONT_VARIABLES.has(variable);
725
784
  }
726
-
727
- /* \u2500\u2500\u2500 Geist Mono \u2500\u2500\u2500 */
728
- @font-face {
729
- font-family: 'Geist Mono';
730
- src: url('/fonts/geist-mono/GeistMono-Variable.woff2') format('woff2');
731
- font-weight: 100 900;
732
- font-style: normal;
733
- font-display: swap;
785
+ function shouldApplyFontUtilityToHtml(font) {
786
+ return !font.font.selector && isRootFontVariable(font.font.variable);
734
787
  }
735
-
736
- @font-face {
737
- font-family: 'Geist Mono';
738
- src: url('/fonts/geist-mono/GeistMono-Italic.woff2') format('woff2');
739
- font-weight: 100 900;
740
- font-style: italic;
741
- font-display: swap;
788
+ function getFontVariableName(importName, variable) {
789
+ const baseName = toCamelCase(importName);
790
+ if (isRootFontVariable(variable)) return baseName;
791
+ return `${baseName}${toPascalCase(variable.replace(/^--font-/, ""))}`;
742
792
  }
743
- `;
744
- var PRELOAD_TAGS = `<link rel="preload" href="/fonts/geist/Geist-Variable.woff2" as="font" type="font/woff2" crossorigin />
745
- <link rel="preload" href="/fonts/geist-mono/GeistMono-Variable.woff2" as="font" type="font/woff2" crossorigin />`;
746
- function setupFontsGeneric(cwd, cssDir) {
747
- copyFonts(cwd);
748
- const fontsPath = path10.join(cssDir, "fonts.css");
749
- if (!fs9.existsSync(fontsPath)) {
750
- fs9.mkdirSync(path10.dirname(fontsPath), { recursive: true });
751
- fs9.writeFileSync(fontsPath, FONTS_CSS_CONTENT, "utf-8");
752
- console.log(` ${CHECK} Created ${path10.relative(cwd, fontsPath)}`);
753
- } else {
754
- console.log(` ${dim(path10.relative(cwd, fontsPath) + " (already exists, skipped)")}`);
755
- }
756
- return { preloadTags: PRELOAD_TAGS };
793
+ function toCamelCase(str) {
794
+ return str.split("_").map(
795
+ (part, index) => index === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()
796
+ ).join("");
757
797
  }
758
-
759
- // src/utils/setup-fonts-nextjs.ts
760
- import fs10 from "fs";
761
- import path11 from "path";
762
- var GEIST_IMPORTS = `import { GeistSans } from "geist/font/sans"
763
- import { GeistMono } from "geist/font/mono"`;
764
- function canPatchLayout(content) {
765
- if (!/<html[\s>]/.test(content)) return false;
766
- if ((content.match(/<html[\s>]/g) || []).length > 1) return false;
767
- return true;
798
+ function toPascalCase(str) {
799
+ return str.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
768
800
  }
769
- function insertImports(content, imports) {
770
- const lines = content.split("\n");
771
- let lastImportIndex = -1;
772
- for (let i = 0; i < lines.length; i++) {
773
- const trimmed = lines[i].trim();
774
- if (trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import ")) {
775
- lastImportIndex = i;
801
+ function findFontVariableDeclaration(sourceFile, variable) {
802
+ for (const statement of sourceFile.getVariableStatements()) {
803
+ for (const declaration of statement.getDeclarations()) {
804
+ const initializer = declaration.getInitializer();
805
+ if (!initializer || initializer.getKind() !== SyntaxKind.CallExpression)
806
+ continue;
807
+ const args = initializer.getArguments();
808
+ if (args.length === 0) continue;
809
+ const argText = args[0].getText();
810
+ if (argText.includes("variable:") && argText.includes(variable)) {
811
+ return declaration;
812
+ }
776
813
  }
777
814
  }
778
- if (lastImportIndex >= 0) {
779
- lines.splice(lastImportIndex + 1, 0, imports);
780
- } else {
781
- lines.unshift(imports);
782
- }
783
- return lines.join("\n");
815
+ return null;
784
816
  }
785
- function addClassNameToHtml(content) {
786
- const fontClasses = "${GeistSans.variable} ${GeistMono.variable}";
787
- const templateMatch = content.match(
788
- /(<html[^>]*className=\{`)(.*?)(`\})/s
789
- );
790
- if (templateMatch) {
791
- const existing = templateMatch[2].trim();
792
- const newClassName = existing ? `${existing} ${fontClasses}` : fontClasses;
793
- return content.replace(
794
- templateMatch[0],
795
- `${templateMatch[1]}${newClassName}${templateMatch[3]}`
796
- );
817
+ function findInsertPosition(sourceFile) {
818
+ const imports = sourceFile.getImportDeclarations();
819
+ if (imports.length > 0) {
820
+ return imports[imports.length - 1].getChildIndex() + 1;
797
821
  }
798
- const exprMatch = content.match(
799
- /(<html[^>]*className=\{)([^}`]+)(\})/
822
+ return 0;
823
+ }
824
+ function updateHtmlClassName(sourceFile, fontVariableNames, fontUtilityClasses) {
825
+ const jsxElements = sourceFile.getDescendantsOfKind(
826
+ SyntaxKind.JsxOpeningElement
800
827
  );
801
- if (exprMatch) {
802
- return content.replace(
803
- exprMatch[0],
804
- `${exprMatch[1]}\`\${${exprMatch[2].trim()}} ${fontClasses}\`${exprMatch[3]}`
828
+ for (const element of jsxElements) {
829
+ if (element.getTagNameNode().getText() !== "html") continue;
830
+ const newUtilityClasses = fontUtilityClasses.map((cls) => `"${cls}"`);
831
+ const newVarExpressions = fontVariableNames.map(
832
+ (name) => `${name}.variable`
805
833
  );
834
+ const allNewArgs = [...newUtilityClasses, ...newVarExpressions];
835
+ const classNameAttr = element.getAttribute("className");
836
+ if (!classNameAttr) {
837
+ element.addAttribute({
838
+ name: "className",
839
+ initializer: `{cn(${allNewArgs.join(", ")})}`
840
+ });
841
+ ensureCnImport(sourceFile);
842
+ return;
843
+ }
844
+ if (classNameAttr.getKind() !== SyntaxKind.JsxAttribute) return;
845
+ const jsxAttr = classNameAttr.asKindOrThrow(SyntaxKind.JsxAttribute);
846
+ const initializer = jsxAttr.getInitializer();
847
+ if (!initializer) return;
848
+ if (initializer.getKind() === SyntaxKind.StringLiteral) {
849
+ const currentValue = initializer.getText().slice(1, -1);
850
+ ensureCnImport(sourceFile);
851
+ jsxAttr.setInitializer(
852
+ `{cn("${currentValue}", ${allNewArgs.join(", ")})}`
853
+ );
854
+ } else if (initializer.getKind() === SyntaxKind.JsxExpression) {
855
+ const jsxExpr = initializer.asKindOrThrow(SyntaxKind.JsxExpression);
856
+ const expr = jsxExpr.getExpression();
857
+ if (!expr) return;
858
+ const exprText = expr.getText();
859
+ if (exprText.startsWith("cn(")) {
860
+ const hasAllFontVars = newVarExpressions.every(
861
+ (v) => exprText.includes(v)
862
+ );
863
+ const hasAllUtilityClasses = fontUtilityClasses.every(
864
+ (cls) => exprText.includes(`"${cls}"`)
865
+ );
866
+ if (hasAllFontVars && hasAllUtilityClasses) continue;
867
+ let cleanedExpr = exprText;
868
+ for (const varExpr of newVarExpressions) {
869
+ cleanedExpr = cleanedExpr.replace(
870
+ new RegExp(`,?\\s*${varExpr.replace(".", "\\.")}`, "g"),
871
+ ""
872
+ );
873
+ }
874
+ cleanedExpr = cleanedExpr.replace(/cn\(\s*,/, "cn(");
875
+ const newExpr = cleanedExpr.replace(
876
+ /\)$/,
877
+ `, ${allNewArgs.join(", ")})`
878
+ );
879
+ jsxExpr.replaceWithText(`{${newExpr}}`);
880
+ } else if (/^\w+\.variable$/.test(exprText)) {
881
+ ensureCnImport(sourceFile);
882
+ jsxExpr.replaceWithText(`{cn(${allNewArgs.join(", ")})}`);
883
+ } else if (exprText.startsWith("`") && exprText.endsWith("`")) {
884
+ const cnArgs = parseTemplateLiteralToCnArgs(exprText);
885
+ ensureCnImport(sourceFile);
886
+ const allNewArgsSet = new Set(allNewArgs);
887
+ const fontFamilyLiterals = new Set(
888
+ ["font-sans", "font-serif", "font-mono"].map((c) => `"${c}"`)
889
+ );
890
+ const cleanedCnArgs = cnArgs.filter(
891
+ (arg) => !allNewArgsSet.has(arg) && !fontFamilyLiterals.has(arg)
892
+ );
893
+ jsxExpr.replaceWithText(
894
+ `{cn(${[...cleanedCnArgs, ...allNewArgs].join(", ")})}`
895
+ );
896
+ } else {
897
+ ensureCnImport(sourceFile);
898
+ jsxExpr.replaceWithText(`{cn(${exprText}, ${allNewArgs.join(", ")})}`);
899
+ }
900
+ }
806
901
  }
807
- const stringMatch = content.match(
808
- /(<html[^>]*className=)(["'])([^"']*)(["'])/
902
+ }
903
+ function ensureCnImport(sourceFile) {
904
+ const existingImport = sourceFile.getImportDeclaration(
905
+ (decl) => decl.getNamedImports().some((imp) => imp.getName() === "cn")
809
906
  );
810
- if (stringMatch) {
811
- const existing = stringMatch[3].trim();
812
- const newClassName = existing ? `${existing} ${fontClasses}` : fontClasses;
813
- return content.replace(
814
- stringMatch[0],
815
- `${stringMatch[1]}{\`${newClassName}\`}`
907
+ if (!existingImport) {
908
+ const utilsImport = sourceFile.getImportDeclaration(
909
+ (decl) => decl.getModuleSpecifierValue().includes("/lib/utils")
816
910
  );
911
+ if (utilsImport) {
912
+ const namedImports = utilsImport.getNamedImports();
913
+ if (!namedImports.some((imp) => imp.getName() === "cn")) {
914
+ utilsImport.addNamedImport("cn");
915
+ }
916
+ } else {
917
+ sourceFile.addImportDeclaration({
918
+ moduleSpecifier: "@/lib/utils",
919
+ namedImports: ["cn"]
920
+ });
921
+ }
817
922
  }
818
- return content.replace(
819
- /<html(\s)/,
820
- `<html className={\`${fontClasses}\`}$1`
821
- );
822
923
  }
823
- function generateSnippet() {
824
- return `
825
- Add these imports to your root layout file:
826
-
827
- ${dim(GEIST_IMPORTS)}
828
-
829
- Then add the font variables to your <html> tag:
830
-
831
- ${dim("<html className={`${GeistSans.variable} ${GeistMono.variable}`}>")}
832
- `;
833
- }
834
- function setupFontsNextjs(cwd, layoutFile, cssFile) {
835
- const layoutPath = path11.join(cwd, layoutFile);
836
- const cssPath = path11.join(cwd, cssFile);
837
- let layoutContent = fs10.readFileSync(layoutPath, "utf-8");
838
- if (layoutContent.includes("geist/font/sans") || layoutContent.includes("GeistSans")) {
839
- console.log(` ${dim("geist/font already configured in " + layoutFile)}`);
840
- return true;
841
- }
842
- if (!canPatchLayout(layoutContent)) {
843
- console.log(` ${WARN} Could not auto-patch ${layoutFile}`);
844
- console.log(generateSnippet());
845
- return false;
846
- }
847
- layoutContent = insertImports(layoutContent, GEIST_IMPORTS);
848
- layoutContent = addClassNameToHtml(layoutContent);
849
- fs10.writeFileSync(layoutPath, layoutContent, "utf-8");
850
- console.log(` ${CHECK} Updated ${layoutFile} with geist/font imports`);
851
- if (fs10.existsSync(cssPath)) {
852
- const patched = patchCssVariables(cssPath, {
853
- "--font-display": "var(--font-geist-sans)",
854
- "--font-body": "var(--font-geist-sans)"
855
- });
856
- if (patched.length > 0) {
857
- console.log(` ${CHECK} Updated font variables in ${path11.relative(cwd, cssPath)}`);
924
+ function parseTemplateLiteralToCnArgs(templateLiteral) {
925
+ const staticArgs = [];
926
+ const variableArgs = [];
927
+ const content = templateLiteral.slice(1, -1);
928
+ const parts = content.split(/(\$\{[^}]+\})/);
929
+ for (const part of parts) {
930
+ if (!part) continue;
931
+ if (part.startsWith("${") && part.endsWith("}")) {
932
+ const expr = part.slice(2, -1).trim();
933
+ if (expr) variableArgs.push(expr);
934
+ } else {
935
+ const staticParts = part.trim().split(/\s+/).filter(Boolean);
936
+ for (const staticPart of staticParts) {
937
+ staticArgs.push(`"${staticPart}"`);
938
+ }
858
939
  }
859
- removeCssImport(cssPath, "./fonts.css");
860
940
  }
861
- return true;
941
+ return [...staticArgs, ...variableArgs];
862
942
  }
863
943
 
864
944
  // src/commands/init.ts
865
- function buildConfig(project, fontChoice) {
945
+ var DEFAULT_FONT_NAMES = ["font-geist", "font-geist-mono"];
946
+ function buildConfig(project, strategy) {
866
947
  let cssPath;
867
948
  if (project.existingCssFile) {
868
949
  cssPath = project.existingCssFile;
@@ -883,16 +964,32 @@ function buildConfig(project, fontChoice) {
883
964
  hooks: "@/hooks"
884
965
  },
885
966
  fonts: {
886
- strategy: fontChoice.strategy,
887
- ...fontChoice.useNextFont ? { nextFont: true } : {}
967
+ strategy
888
968
  }
889
969
  };
890
970
  }
891
- function stripTailwindCoreImport(content) {
892
- return content.split("\n").filter((line) => !line.trim().match(/^@import\s+["']tailwindcss["']/)).join("\n");
971
+ function stripImportLines(content) {
972
+ const lines = content.split("\n");
973
+ const imports = [];
974
+ const kept = [];
975
+ for (const line of lines) {
976
+ if (line.trim().match(/^@import\s+["'].+["']/)) {
977
+ if (!line.trim().match(/^@import\s+["']tailwindcss["']/)) {
978
+ imports.push(line.trim());
979
+ }
980
+ } else {
981
+ kept.push(line);
982
+ }
983
+ }
984
+ return { content: kept.join("\n"), imports };
893
985
  }
894
986
  function insertImportLine(filePath, importStatement) {
895
- const content = fs11.readFileSync(filePath, "utf-8");
987
+ const content = fs9.readFileSync(filePath, "utf-8");
988
+ const importPath = importStatement.match(/["']([^"']+)["']/)?.[1];
989
+ if (importPath) {
990
+ const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
991
+ if (new RegExp(`@import\\s+["']${escaped}["']`).test(content)) return;
992
+ }
896
993
  const lines = content.split("\n");
897
994
  let lastImportIndex = -1;
898
995
  for (let i = 0; i < lines.length; i++) {
@@ -905,17 +1002,12 @@ function insertImportLine(filePath, importStatement) {
905
1002
  } else {
906
1003
  lines.unshift(importStatement);
907
1004
  }
908
- fs11.writeFileSync(filePath, lines.join("\n"), "utf-8");
1005
+ fs9.writeFileSync(filePath, lines.join("\n"), "utf-8");
909
1006
  }
910
- async function promptFontChoice(project, detected, skipPrompts) {
1007
+ async function promptFontStrategy(detected, skipPrompts) {
911
1008
  const hasExistingFonts = detected.hasNextFont || detected.hasFontFace || detected.hasGoogleFontsLink;
912
1009
  const defaultStrategy = hasExistingFonts ? "keep-existing" : "stera-default";
913
- if (skipPrompts) {
914
- return {
915
- strategy: defaultStrategy,
916
- useNextFont: project.framework === "next" && defaultStrategy === "stera-default"
917
- };
918
- }
1010
+ if (skipPrompts) return defaultStrategy;
919
1011
  if (hasExistingFonts) {
920
1012
  const sources = [];
921
1013
  if (detected.fontFamilies.length > 0) {
@@ -937,7 +1029,7 @@ async function promptFontChoice(project, detected, skipPrompts) {
937
1029
  choices.push({
938
1030
  value: "stera-default",
939
1031
  name: "Use Stera UI default fonts (Geist Sans + Geist Mono)",
940
- description: "Self-hosted, optimized variable fonts"
1032
+ description: "Optimized variable fonts via next/font or fontsource"
941
1033
  });
942
1034
  choices.push({
943
1035
  value: "skip",
@@ -949,56 +1041,79 @@ async function promptFontChoice(project, detected, skipPrompts) {
949
1041
  choices,
950
1042
  default: defaultStrategy
951
1043
  });
952
- let useNextFont = false;
953
- if (project.framework === "next" && strategy === "stera-default") {
954
- useNextFont = await confirm({
955
- message: "Use next/font for automatic optimization? (recommended)",
956
- default: true
957
- });
958
- }
959
1044
  console.log("");
960
- return { strategy, useNextFont };
1045
+ return strategy;
1046
+ }
1047
+ async function fetchDefaultFonts() {
1048
+ const fonts = [];
1049
+ for (const name of DEFAULT_FONT_NAMES) {
1050
+ try {
1051
+ const item = await fetchRegistryItem(name);
1052
+ if (item.type === "registry:font") {
1053
+ fonts.push(item);
1054
+ }
1055
+ } catch {
1056
+ }
1057
+ }
1058
+ return fonts;
961
1059
  }
962
- async function executeFontStrategy(fontChoice, project, detected, config, cwd) {
963
- const cssDir = path12.dirname(path12.resolve(cwd, config.css));
964
- const cssPath = path12.resolve(cwd, config.css);
965
- if (fontChoice.strategy === "skip") {
966
- return {};
967
- }
968
- if (fontChoice.strategy === "keep-existing") {
969
- if (fs11.existsSync(cssPath)) {
1060
+ async function executeFontStrategy(strategy, project, detected, config, cwd) {
1061
+ if (strategy === "skip") return;
1062
+ if (strategy === "keep-existing") {
1063
+ const cssPath = path10.resolve(cwd, config.css);
1064
+ const cssDir = path10.dirname(cssPath);
1065
+ if (fs9.existsSync(cssPath)) {
970
1066
  const fontFamily = detected.fontFamilies.length > 0 ? detected.fontFamilies[0] : null;
971
1067
  const fontValue = detected.nextFontVariable ? `var(${detected.nextFontVariable})` : fontFamily ?? "Geist";
972
1068
  const patched = patchCssVariables(cssPath, {
973
- "--font-display": fontValue,
974
- "--font-body": fontValue
1069
+ "--font-heading": fontValue,
1070
+ "--font-sans": fontValue
975
1071
  });
976
- const steraUiPath = path12.join(cssDir, "stera-ui.css");
977
- if (fs11.existsSync(steraUiPath)) {
1072
+ const steraUiPath = path10.join(cssDir, "stera-ui.css");
1073
+ if (fs9.existsSync(steraUiPath)) {
978
1074
  patchCssVariables(steraUiPath, {
979
- "--font-display": fontValue,
980
- "--font-body": fontValue
1075
+ "--font-heading": fontValue,
1076
+ "--font-sans": fontValue
981
1077
  });
982
1078
  }
983
1079
  if (patched.length > 0) {
984
1080
  console.log(` ${CHECK} Updated font variables to use ${fontValue}`);
985
1081
  }
986
1082
  }
987
- return {};
1083
+ return;
988
1084
  }
989
- if (fontChoice.useNextFont && project.layoutFile) {
990
- await installDependencies(["geist"], cwd);
991
- const targetCss = fs11.existsSync(path12.join(cssDir, "stera-ui.css")) ? path12.join(cssDir, "stera-ui.css") : cssPath;
992
- setupFontsNextjs(cwd, project.layoutFile, targetCss);
993
- return {};
994
- } else {
995
- await installDependencies(["geist"], cwd);
996
- const result = setupFontsGeneric(cwd, cssDir);
997
- return { preloadTags: result.preloadTags };
1085
+ const fonts = await fetchDefaultFonts();
1086
+ if (fonts.length === 0) return;
1087
+ const fontTree = massageTreeForFonts(fonts, project);
1088
+ if (fontTree.dependencies.length > 0) {
1089
+ await installDependencies(fontTree.dependencies, cwd);
1090
+ }
1091
+ if (fontTree.cssImports.length > 0) {
1092
+ const cssPath = path10.resolve(cwd, config.css);
1093
+ const cssDir = path10.dirname(cssPath);
1094
+ const targetCss = fs9.existsSync(path10.join(cssDir, "stera-ui.css")) ? path10.join(cssDir, "stera-ui.css") : cssPath;
1095
+ if (fs9.existsSync(targetCss)) {
1096
+ for (const importLine of fontTree.cssImports) {
1097
+ insertImportLine(targetCss, importLine);
1098
+ }
1099
+ console.log(` ${CHECK} Added font imports to ${path10.relative(cwd, targetCss)}`);
1100
+ }
998
1101
  }
1102
+ if (Object.keys(fontTree.cssVars).length > 0) {
1103
+ const cssPath = path10.resolve(cwd, config.css);
1104
+ const cssDir = path10.dirname(cssPath);
1105
+ const targetCss = fs9.existsSync(path10.join(cssDir, "stera-ui.css")) ? path10.join(cssDir, "stera-ui.css") : cssPath;
1106
+ if (fs9.existsSync(targetCss)) {
1107
+ const patched = patchCssVariables(targetCss, fontTree.cssVars);
1108
+ if (patched.length > 0) {
1109
+ console.log(` ${CHECK} Updated font variables in ${path10.relative(cwd, targetCss)}`);
1110
+ }
1111
+ }
1112
+ }
1113
+ await updateFonts(fonts, project, cwd);
999
1114
  }
1000
1115
  async function init(options) {
1001
- const cwd = options.cwd ? path12.resolve(options.cwd) : process.cwd();
1116
+ const cwd = options.cwd ? path10.resolve(options.cwd) : process.cwd();
1002
1117
  console.log(`
1003
1118
  ${LOGO} Stera UI
1004
1119
  `);
@@ -1008,8 +1123,8 @@ async function init(options) {
1008
1123
  console.log(' Run "stera-ui add <component>" to add components.\n');
1009
1124
  return;
1010
1125
  }
1011
- const pkgPath = path12.join(cwd, "package.json");
1012
- if (!fs11.existsSync(pkgPath)) {
1126
+ const pkgPath = path10.join(cwd, "package.json");
1127
+ if (!fs9.existsSync(pkgPath)) {
1013
1128
  console.error(
1014
1129
  " Error: No package.json found. Please run this in a project directory."
1015
1130
  );
@@ -1025,35 +1140,37 @@ async function init(options) {
1025
1140
  `);
1026
1141
  }
1027
1142
  const detectedFonts = detectFonts(cwd, project);
1028
- const fontChoice = await promptFontChoice(
1029
- project,
1143
+ const strategy = await promptFontStrategy(
1030
1144
  detectedFonts,
1031
1145
  options.yes ?? false
1032
1146
  );
1033
- const config = buildConfig(project, fontChoice);
1034
- const configPath = path12.join(cwd, CONFIG_FILE);
1035
- fs11.writeFileSync(
1147
+ const config = buildConfig(project, strategy);
1148
+ const configPath = path10.join(cwd, CONFIG_FILE);
1149
+ fs9.writeFileSync(
1036
1150
  configPath,
1037
1151
  JSON.stringify(config, null, 2) + "\n",
1038
1152
  "utf-8"
1039
1153
  );
1040
1154
  console.log(` ${CHECK} Created components.json`);
1041
- const cssDir = path12.dirname(path12.resolve(cwd, config.css));
1155
+ const cssDir = path10.dirname(path10.resolve(cwd, config.css));
1042
1156
  if (project.existingCssFile) {
1043
1157
  if (hasGlobalsCss(config, cwd)) {
1044
1158
  console.log(
1045
- ` ${CHECK} Stera already initialised ${dim(`(${path12.join(path12.dirname(project.existingCssFile), "stera-ui.css")} exists)`)}`
1159
+ ` ${CHECK} Stera already initialised ${dim(`(${path10.join(path10.dirname(project.existingCssFile), "stera-ui.css")} exists)`)}`
1046
1160
  );
1047
1161
  } else {
1048
1162
  try {
1049
1163
  const globalsItem = await fetchRegistryItem("globals");
1050
- const globalsContent = globalsItem.files[0].content ?? "";
1051
- const steraUiContent = stripTailwindCoreImport(globalsContent);
1052
- const steraUiPath = path12.join(cssDir, "stera-ui.css");
1053
- fs11.mkdirSync(cssDir, { recursive: true });
1054
- fs11.writeFileSync(steraUiPath, steraUiContent, "utf-8");
1055
- console.log(` ${CHECK} Created ${path12.relative(cwd, steraUiPath)}`);
1056
- const existingCssPath = path12.resolve(cwd, project.existingCssFile);
1164
+ const globalsContent = globalsItem.files?.[0]?.content ?? "";
1165
+ const { content: steraUiContent, imports } = stripImportLines(globalsContent);
1166
+ const steraUiPath = path10.join(cssDir, "stera-ui.css");
1167
+ fs9.mkdirSync(cssDir, { recursive: true });
1168
+ fs9.writeFileSync(steraUiPath, steraUiContent, "utf-8");
1169
+ console.log(` ${CHECK} Created ${path10.relative(cwd, steraUiPath)}`);
1170
+ const existingCssPath = path10.resolve(cwd, project.existingCssFile);
1171
+ for (const imp of imports) {
1172
+ insertImportLine(existingCssPath, imp);
1173
+ }
1057
1174
  insertImportLine(existingCssPath, "@import './stera-ui.css';");
1058
1175
  console.log(` ${CHECK} Added @import to ${project.existingCssFile}`);
1059
1176
  } catch {
@@ -1061,13 +1178,14 @@ async function init(options) {
1061
1178
  }
1062
1179
  } else {
1063
1180
  const resolved = await resolveDependencies(["globals"]);
1064
- const { written } = await writeComponentFiles(resolved, config, cwd);
1181
+ const filtered = resolved.filter((item) => item.name !== "fonts");
1182
+ const { written } = await writeComponentFiles(filtered, config, cwd);
1065
1183
  for (const file of written) {
1066
1184
  console.log(` ${CHECK} Created ${file}`);
1067
1185
  }
1068
1186
  }
1069
- const fontResult = await executeFontStrategy(
1070
- fontChoice,
1187
+ await executeFontStrategy(
1188
+ strategy,
1071
1189
  project,
1072
1190
  detectedFonts,
1073
1191
  config,
@@ -1085,15 +1203,7 @@ async function init(options) {
1085
1203
  console.log("");
1086
1204
  console.log(" Next steps:");
1087
1205
  console.log(" stera-ui add <component>");
1088
- if (fontResult.preloadTags) {
1089
- console.log("");
1090
- console.log(` ${dim("For optimal font loading, add these to your HTML <head>:")}`);
1091
- console.log("");
1092
- for (const line of fontResult.preloadTags.split("\n")) {
1093
- console.log(` ${dim(line)}`);
1094
- }
1095
- }
1096
- if (fontChoice.strategy === "skip") {
1206
+ if (strategy === "skip") {
1097
1207
  console.log("");
1098
1208
  console.log(` ${dim("Font setup was skipped. See https://ui.stera.sh for font configuration docs.")}`);
1099
1209
  }