From 760703bebc7e1dc7957095f21b6390cbf6b3656e Mon Sep 17 00:00:00 2001 From: Ahmad Hakim Date: Fri, 7 Aug 2026 22:06:21 +0200 Subject: [PATCH 1/3] component.json versioning --- cli/main.py | 98 +++++++++++++++++++++++++++++++++--- scripts/generate_registry.py | 11 +++- 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/cli/main.py b/cli/main.py index 4368fe0..9fe7884 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,5 +1,7 @@ import argparse import ast +import json +import re import shutil import sys from pathlib import Path @@ -120,19 +122,66 @@ def get_source_root() -> Path | None: return None -def resolve_dependencies(component_names: list[str], registry: dict) -> list[str]: +def parse_component_spec(spec: str) -> tuple[str, str | None]: + """Parse component spec like 'button@1.0.0' into ('button', '1.0.0').""" + if "@" in spec: + name, version = spec.split("@", 1) + return name.lower(), version + return spec.lower(), None + + +def parse_version_key(version_str: str) -> list[int]: + """Helper to sort versions semantically (e.g. '2.0.0' > '1.0.0' > '1').""" + return [int(x) for x in re.findall(r"\d+", version_str)] + + +def get_latest_version(component_name: str, registry: dict) -> str | None: + """Find the latest registered version of a component.""" + target = component_name.lower() + versions = [] + + for key in registry: + if key.startswith(f"{target}@"): + version = key.split("@", 1)[1] + versions.append(version) + + if not versions: + if target in registry: + return target + return None + + versions.sort(key=parse_version_key) + return f"{target}@{versions[-1]}" + + +def resolve_dependencies( + component_names: list[str], registry: dict +) -> tuple[list[str], dict[str, str]]: required_files: set[str] = set() visited: set[str] = set() + installed_map: dict[str, str] = {} + + def add(spec: str): + key = spec.lower() + if "@" not in key: + resolved_key = get_latest_version(key, registry) + if not resolved_key: + print(f"Warning: Component '{spec}' not found in registry.") + return + key = resolved_key - def add(name: str): - key = name.lower() if key in visited: return visited.add(key) + entry = registry.get(key) if not entry: - print(f"Warning: Component '{name}' not found in registry.") + print(f"Warning: Component '{spec}' not found in registry.") return + + name, version = parse_component_spec(key) + installed_map[name] = version or "latest" + for f in entry["files"]: required_files.add(f) for dep in entry.get("dependencies", []): @@ -141,14 +190,48 @@ def add(name: str): for name in component_names: add(name) - return sorted(required_files) + return sorted(required_files), installed_map + + +def update_manifest(target_root: Path, installed_map: dict[str, str]) -> None: + """Update the buridan.json manifest with newly installed components.""" + manifest_path = target_root / "buridan.json" + manifest = {"components": {}} + + if manifest_path.exists(): + try: + manifest = json.loads(manifest_path.read_text()) + except Exception: + pass + + if "components" not in manifest: + manifest["components"] = {} + + for name, version in installed_map.items(): + manifest["components"][name] = version + + manifest_path.write_text(json.dumps(manifest, indent=2)) + print(f"• Updated manifest: {manifest_path.name}") BLOCK_SOURCE_PREFIX = "native/lib/blocks/" def remap_dest(rel: str) -> str: - """Remap block source paths to blocks/ in the user's project root.""" + """Remap block source paths to blocks/ in the user's project root, + and flatten versioned component subdirectories. + + Example: components/ui/button/v1.py -> components/ui/button.py + """ + path = Path(rel) + # Detect versioned components like components/ui/button/v1.py + if ( + len(path.parts) >= 4 + and path.parts[0] == "components" + and re.match(r"^v\d+(?:_\d+)*$", path.stem) + ): + return str(Path(path.parent.parent) / f"{path.parent.name}.py") + if rel.startswith(BLOCK_SOURCE_PREFIX): return "blocks/" + Path(rel).name return rel @@ -171,7 +254,7 @@ def add_components_to_project( print("Error: Could not locate Buridan source components.") return False - files = resolve_dependencies(component_names, COMPONENT_REGISTRY) + files, installed_map = resolve_dependencies(component_names, COMPONENT_REGISTRY) if not files: print("No files to copy.") return False @@ -198,6 +281,7 @@ def add_components_to_project( shutil.copy2(src, dest) print(f"✓ Added {remap_dest(rel)}") + update_manifest(target_root, installed_map) return True diff --git a/scripts/generate_registry.py b/scripts/generate_registry.py index def4b47..ad8f2c1 100644 --- a/scripts/generate_registry.py +++ b/scripts/generate_registry.py @@ -38,6 +38,7 @@ import argparse import ast +import re import sys from dataclasses import dataclass, field from pathlib import Path @@ -102,7 +103,15 @@ def build_component_index( rel = path.relative_to(repo_root) rel_posix = rel.as_posix() stem = path.stem - name = _unique_name(stem, rel_posix, seen_stems) + + # Check if the file is a versioned file (e.g. v1, v1_0, v2_0_0) + # under a component directory. + if re.match(r"^v\d+(?:_\d+)*$", stem) and len(path.parent.name) > 0: + component_name = path.parent.name + version = stem[1:].replace("_", ".") + name = f"{component_name}@{version}" + else: + name = _unique_name(stem, rel_posix, seen_stems) module_parts = rel.with_suffix("").parts # e.g. ('components','ui','button') dotted_module = ".".join(module_parts) From 7199d59d44e05025c2256c220d5524850274efd3 Mon Sep 17 00:00:00 2001 From: Ahmad Hakim Date: Sun, 9 Aug 2026 15:28:46 +0200 Subject: [PATCH 2/3] finalize versioning & componets.json --- assets/docs/getting-started/changelog.md | 17 +- assets/docs/getting-started/cli.md | 37 +++- .../docs/getting-started/components-json.md | 111 ++++++++++++ assets/llms.txt | 1 + assets/social/components-json.webp | Bin 0 -> 7318 bytes cli/main.py | 169 +++++++++++++----- docs/getting_started/changelog.md | 17 +- docs/getting_started/cli.md | 37 +++- docs/getting_started/components-json.md | 111 ++++++++++++ native/engine/generator.py | 2 - native/pages/landing.py | 5 +- native/templates/navbar.py | 17 +- scripts/generate_registry.py | 155 +++++++++------- 13 files changed, 560 insertions(+), 119 deletions(-) create mode 100644 assets/docs/getting-started/components-json.md create mode 100644 assets/social/components-json.webp create mode 100644 docs/getting_started/components-json.md diff --git a/assets/docs/getting-started/changelog.md b/assets/docs/getting-started/changelog.md index 245bb1d..3d76f68 100644 --- a/assets/docs/getting-started/changelog.md +++ b/assets/docs/getting-started/changelog.md @@ -8,11 +8,24 @@ order: 5 Latest updates and announcements. +# August 2026 - Component Versioning & components.json + +As Buridan UI's component library has grown, we've run into a familiar problem: components change over time, and sometimes those changes aren't backward compatible. Until now, updating a component in the registry meant every project pulling that component got the new version, whether it was ready for it or not. This update fixes that. + +Components can now publish multiple versions side by side, and the CLI lets you choose exactly which one you want. + +- **Version pinning:** Install a specific version of any component with `buridan add button@1.0.0`, or leave off the version to get the latest one automatically. Your project stays exactly as you set it up, even as new versions get published to the registry. +- **Smart dependency resolution:** If a component you're adding depends on another versioned component, the CLI resolves it sensibly. An explicit version you pin always takes priority over whatever a dependency would otherwise pull in, so you're never surprised by a version swap you didn't ask for. +- **A new components.json manifest:** Every `buridan add` and `buridan apply` now records what's actually installed in your project, including each component's version and your currently applied theme preset. It's a simple, readable log of your project's setup, not something you need to edit by hand, but useful to have around when you're checking what's installed or diffing changes over time. +- **Clear conflict handling:** If two different versions of the same component end up requested at once, the CLI warns you about it instead of quietly picking one and leaving you to find out later. + +None of this changes how components look or behave today. It just means your project can move at its own pace as the registry evolves underneath it. + # July 2026 - Native/Buridan Under-the-hood layouts can get incredibly messy when they are wrapped in too many heavy React abstractions. In this update, we went back to the drawing board and completely rewrote Buridan's core elements to rely purely on native HTML elements (`rx.el.*`) instead of wrapping everything in heavy, custom third-party components. -By ditching complex client-side libraries like Radix UI and Base UI, we've stripped away massive JavaScript bundles and unnecessary DOM nodes. +By ditching complex client-side libraries like Radix UI and Base UI, we've stripped away massive JavaScript bundles and unnecessary DOM nodes. We did this for a couple of really practical reasons that make a huge difference in day-to-day development: @@ -21,4 +34,4 @@ We did this for a couple of really practical reasons that make a huge difference - **Featherlight DOM Overhead:** Stripping out nested wrapper divs means the browser has fewer nodes to paint. Your rendered page markup is incredibly clean, which makes styling adjustments with Tailwind CSS utilities extremely predictable—no more fighting arbitrary class specificity clashes. - **Predictable 1:1 API mapping:** Native tags are standard. There are no hidden proprietary parameters, undocumented properties, or unexpected behavioral overrides. What you write in your Python code maps 1:1 with what the browser actually renders in the DOM tree. -It’s a simpler, much more robust foundation that keeps your apps lightweight, super-fast, and incredibly responsive. +It's a simpler, much more robust foundation that keeps your apps lightweight, super-fast, and incredibly responsive. diff --git a/assets/docs/getting-started/cli.md b/assets/docs/getting-started/cli.md index 4a81922..ee3d986 100644 --- a/assets/docs/getting-started/cli.md +++ b/assets/docs/getting-started/cli.md @@ -34,7 +34,7 @@ buridan init # apply -Apply a theme preset to your project. Generates `:root` and `.dark` CSS variable blocks in `assets/globals.css` based on the preset ID from the theme builder. +Apply a theme preset to your project. Generates `:root` and `.dark` CSS variable blocks in `assets/globals.css` based on the preset ID from the theme builder, and records the applied theme in [`components.json`](/docs/components-json). ```bash buridan apply --preset @@ -51,6 +51,8 @@ buridan apply --preset b0 buridan apply --preset b2D0wqNxT ``` +Re-running `buridan apply` with a different preset overwrites the theme section of `components.json` and the generated CSS variables — it does not touch any components you've already added. + # add Add components and their dependencies to your project. @@ -71,13 +73,42 @@ Blocks (charts, dashboards, etc.) can be added the same way: buridan add line_chart_01 ``` -Components are placed in `components/`, blocks in `blocks/`. Dependencies are resolved and added automatically. +Components are placed in `components/`, blocks in `blocks/`. Dependencies are resolved and added automatically. Every install updates the `components` section of [`components.json`](/docs/components-json) with the name and version of each installed component. > **Note:** Components require a theme to render correctly. Run `buridan apply` before using components. +## Versioning + +Some components have more than one published version. By default, `add` installs the latest available version: + +```bash +buridan add button +``` + +To pin a specific version, append `@` to the component name: + +```bash +buridan add button@1.0.0 +``` + +Version pins you specify explicitly always take priority over versions pulled in automatically by another component's dependencies. For example, if you run: + +```bash +buridan add card button@1.0.0 +``` + +and `card` normally depends on the latest `button`, your explicit `button@1.0.0` pin wins — `card` will be installed against `button@1.0.0`, not whatever the latest version happens to be. + +If you pin the same component to two different versions in one command (or the CLI otherwise detects a genuine conflict it can't resolve), it prints a warning and keeps the first version it resolved: + +```bash +buridan add button@1.0.0 button@2.0.0 +# Warning: 'button' requested at both 1.0.0 and 2.0.0; keeping 1.0.0. +``` + # list -Display all available components and blocks. +Display all available components and blocks. If a component has multiple published versions, each version is listed separately (e.g. `button@1.0.0`, `button@2.0.0`). ```bash buridan list diff --git a/assets/docs/getting-started/components-json.md b/assets/docs/getting-started/components-json.md new file mode 100644 index 0000000..05cb64c --- /dev/null +++ b/assets/docs/getting-started/components-json.md @@ -0,0 +1,111 @@ +--- +title: "components.json" +description: "Configuration and state tracked automatically for your project." +order: 4 +--- + +# components.json + +The `components.json` file tracks the components and theme currently installed in your project. + +Unlike some other CLI tools, you don't create or edit this file by hand, the `buridan` CLI creates and updates it automatically the first time you run `buridan apply` or `buridan add`. Its main purpose is to give you (and the CLI) a single place to see exactly what's installed, at what version, and under what theme. + +**Note:** `components.json` is written to the root of your Reflex project, alongside `rxconfig.py`. It's safe to commit to version control, treat it like a lockfile for your design system. + +# Structure + +```json +{ + "components": { + "button": "1.0.0", + "card": "latest", + "core": "latest" + }, + "theme": { + "preset": "b0", + "baseId": "neutral", + "colorId": "blue", + "chartId": "blue", + "styleId": "default", + "fontId": "inter", + "radius": "0.5rem" + } +} +``` + +The two sections are updated independently, running `buridan add` never touches `theme`, and running `buridan apply` never touches `components`. + +# components + +A map of installed component names to the version installed. + +```json +{ + "components": { + "button": "1.0.0" + } +} +``` + +- The key is the component's registry name, without any `@version` suffix. +- The value is either a specific version string (e.g. `"1.0.0"`) if you pinned one with `buridan add button@1.0.0`, or the literal string `"latest"` if you installed it unpinned, or if the component doesn't have multiple published versions. + +This section is updated every time you run `buridan add`. See the [CLI docs](/docs/getting-started/cli#versioning) for how version pinning and conflicts are resolved. + +# theme + +Records the theme preset currently applied to your project. + +```json +{ + "theme": { + "preset": "b0", + "baseId": "neutral", + "colorId": "blue", + "chartId": "blue", + "styleId": "default", + "fontId": "inter", + "radius": "0.5rem" + } +} +``` + +This section is written by `buridan apply --preset ` and fully overwritten (not merged) on every subsequent `apply`, it always reflects only the most recently applied preset. + +### theme.preset + +The raw preset ID from the [theme builder](/docs/getting-started/cli#create), exactly as passed to `--preset`. This is the source of truth for the rest of the fields below, since it fully encodes the theme, it's the most reliable value to reference if you ever need to reconstruct or re-apply the same theme programmatically. + +```json +{ + "theme": { + "preset": "b2D0wqNxT" + } +} +``` + +### theme.baseId + +The base theme (background, foreground, and neutral tones) the preset is built on. + +### theme.colorId + +The accent color palette applied on top of the base theme. + +### theme.chartId + +The color palette used for chart-specific CSS variables (`--chart-1` through `--chart-5`, etc.). This can differ from `colorId` if the preset uses a separate chart palette. + +### theme.styleId + +The component style variant (e.g. spacing, shadow, and border conventions) the preset applies. + +### theme.fontId + +The font family applied by the preset. + +### theme.radius + +The border-radius value applied by the preset, e.g. `"0.5rem"`. Omitted from `components.json` if the preset doesn't override the style's own default radius. + +Any field above that wasn't set by a given preset is left out of `components.json` entirely rather than written as `null`, so the file only ever shows what was actually specified. diff --git a/assets/llms.txt b/assets/llms.txt index 71075c3..e7103ed 100644 --- a/assets/llms.txt +++ b/assets/llms.txt @@ -9,6 +9,7 @@ - [CLI](https://native.buridan.dev/docs/getting-started/cli): CLI page. - [dev.py](https://native.buridan.dev/docs/getting-started/dev): dev.py page. - [JavaScript](https://native.buridan.dev/docs/getting-started/javascript): JavaScript page. +- [components.json](https://native.buridan.dev/docs/getting-started/components-json): components.json page. - [Changelog](https://native.buridan.dev/docs/getting-started/changelog): Changelog page. ## Resources diff --git a/assets/social/components-json.webp b/assets/social/components-json.webp new file mode 100644 index 0000000000000000000000000000000000000000..889af0f11130e1c05dbb21624d2b98713501730e GIT binary patch literal 7318 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 literal 0 HcmV?d00001 diff --git a/cli/main.py b/cli/main.py index 9fe7884..9b2caf4 100644 --- a/cli/main.py +++ b/cli/main.py @@ -139,17 +139,17 @@ def get_latest_version(component_name: str, registry: dict) -> str | None: """Find the latest registered version of a component.""" target = component_name.lower() versions = [] - + for key in registry: if key.startswith(f"{target}@"): version = key.split("@", 1)[1] versions.append(version) - + if not versions: if target in registry: return target return None - + versions.sort(key=parse_version_key) return f"{target}@{versions[-1]}" @@ -157,61 +157,147 @@ def get_latest_version(component_name: str, registry: dict) -> str | None: def resolve_dependencies( component_names: list[str], registry: dict ) -> tuple[list[str], dict[str, str]]: + """Resolve a list of top-level component specs (with optional @version) + plus their transitive dependencies into a file list and a name->version + map suitable for the manifest. + + Components are deduped by NAME, not by name+version — two different + versions of the same component are never both queued for copy. If the + same component is reachable at two different versions (e.g. pinned + explicitly on the command line, but also pulled in unpinned by another + component's dependencies), the version resolved from an explicit + top-level request always wins; a conflict pulled in purely via + dependencies is resolved by first-come and a warning is printed. + """ required_files: set[str] = set() - visited: set[str] = set() - installed_map: dict[str, str] = {} + resolved: dict[str, str] = {} # component name -> chosen registry key + order: list[str] = [] # first-resolved order, for a stable installed_map + warned: set[str] = set() - def add(spec: str): + def resolve_key(spec: str) -> str | None: key = spec.lower() - if "@" not in key: - resolved_key = get_latest_version(key, registry) - if not resolved_key: - print(f"Warning: Component '{spec}' not found in registry.") - return - key = resolved_key - - if key in visited: - return - visited.add(key) - - entry = registry.get(key) - if not entry: - print(f"Warning: Component '{spec}' not found in registry.") - return - - name, version = parse_component_spec(key) - installed_map[name] = version or "latest" - + if "@" in key: + return key if key in registry else None + return get_latest_version(key, registry) + + def claim(name: str, key: str) -> bool: + """Register `name` as resolved to `key`. Returns True if this call + actually claimed it (False if already claimed by something else).""" + if name in resolved: + if resolved[name] != key and name not in warned: + _, existing_version = parse_component_spec(resolved[name]) + _, new_version = parse_component_spec(key) + print( + f"Warning: '{name}' requested at both " + f"{existing_version or 'latest'} and {new_version or 'latest'}; " + f"keeping {existing_version or 'latest'}." + ) + warned.add(name) + return False + resolved[name] = key + order.append(name) + return True + + def add_files_and_deps(name: str, key: str): + entry = registry[key] for f in entry["files"]: required_files.add(f) for dep in entry.get("dependencies", []): add(dep) - for name in component_names: - add(name) - + def add(spec: str): + key = resolve_key(spec) + if not key: + print(f"Warning: Component '{spec}' not found in registry.") + return + name, _ = parse_component_spec(key) + if not claim(name, key): + return + add_files_and_deps(name, key) + + # First pass: claim every explicitly-requested component up front, so a + # pinned version always wins over whatever a dependency graph resolves + # to later, regardless of command-line order. + explicit_names: list[str] = [] + for spec in component_names: + key = resolve_key(spec) + if not key: + print(f"Warning: Component '{spec}' not found in registry.") + continue + name, _ = parse_component_spec(key) + claim(name, key) + explicit_names.append(name) + + # Second pass: pull in files + dependencies for each explicit component, + # using resolved[name] — the version that actually won the claim above — + # not whichever key that particular spec happened to resolve to. + for name in dict.fromkeys(explicit_names): # dedupe, keep first-seen order + add_files_and_deps(name, resolved[name]) + + installed_map = { + name: (parse_component_spec(resolved[name])[1] or "latest") for name in order + } return sorted(required_files), installed_map -def update_manifest(target_root: Path, installed_map: dict[str, str]) -> None: - """Update the buridan.json manifest with newly installed components.""" - manifest_path = target_root / "buridan.json" - manifest = {"components": {}} +def load_manifest(target_root: Path) -> dict: + """Load components.json, tolerating a missing or corrupt file. + Always returns a dict with at least 'components' and 'theme' keys. + """ + manifest_path = target_root / "components.json" + manifest: dict = {} if manifest_path.exists(): try: - manifest = json.loads(manifest_path.read_text()) + loaded = json.loads(manifest_path.read_text()) + if isinstance(loaded, dict): + manifest = loaded + else: + print( + "Warning: components.json content was not a JSON object; recreating it." + ) except Exception: - pass + print("Warning: components.json was unreadable; recreating it.") - if "components" not in manifest: - manifest["components"] = {} + manifest.setdefault("components", {}) + manifest.setdefault("theme", {}) + return manifest - for name, version in installed_map.items(): - manifest["components"][name] = version +def save_manifest(target_root: Path, manifest: dict) -> None: + manifest_path = target_root / "components.json" manifest_path.write_text(json.dumps(manifest, indent=2)) - print(f"• Updated manifest: {manifest_path.name}") + + +def update_manifest(target_root: Path, installed_map: dict[str, str]) -> None: + """Merge newly installed components into components.json without + touching the theme section.""" + manifest = load_manifest(target_root) + manifest["components"].update(installed_map) + save_manifest(target_root, manifest) + print("• Updated manifest: components.json") + + +def update_manifest_theme(target_root: Path, preset: str, config: dict) -> None: + """Record the currently-applied theme preset in components.json without + touching the components section.""" + manifest = load_manifest(target_root) + + theme_data = { + "preset": preset, + "baseId": config.get("baseId"), + "colorId": config.get("colorId"), + "chartId": config.get("chartId"), + "styleId": config.get("styleId"), + "fontId": config.get("fontId"), + "radius": config.get("radius"), + } + # Drop unset fields rather than writing nulls — keeps the file readable + # and makes it obvious which parts of the preset were actually specified. + manifest["theme"] = {k: v for k, v in theme_data.items() if v is not None} + + save_manifest(target_root, manifest) + print("• Updated manifest: components.json (theme)") BLOCK_SOURCE_PREFIX = "native/lib/blocks/" @@ -220,7 +306,7 @@ def update_manifest(target_root: Path, installed_map: dict[str, str]) -> None: def remap_dest(rel: str) -> str: """Remap block source paths to blocks/ in the user's project root, and flatten versioned component subdirectories. - + Example: components/ui/button/v1.py -> components/ui/button.py """ path = Path(rel) @@ -481,6 +567,9 @@ def cmd_apply(preset: str): css_path.write_text(theme_css) print(f"✓ Applied preset '{preset}' to globals.css") + + update_manifest_theme(root, preset, config) + print("\nNext steps:") print(" Add globals.css to your app stylesheets if you haven't already:") print(' app = rx.App(stylesheets=["globals.css"])') diff --git a/docs/getting_started/changelog.md b/docs/getting_started/changelog.md index 245bb1d..3d76f68 100644 --- a/docs/getting_started/changelog.md +++ b/docs/getting_started/changelog.md @@ -8,11 +8,24 @@ order: 5 Latest updates and announcements. +# August 2026 - Component Versioning & components.json + +As Buridan UI's component library has grown, we've run into a familiar problem: components change over time, and sometimes those changes aren't backward compatible. Until now, updating a component in the registry meant every project pulling that component got the new version, whether it was ready for it or not. This update fixes that. + +Components can now publish multiple versions side by side, and the CLI lets you choose exactly which one you want. + +- **Version pinning:** Install a specific version of any component with `buridan add button@1.0.0`, or leave off the version to get the latest one automatically. Your project stays exactly as you set it up, even as new versions get published to the registry. +- **Smart dependency resolution:** If a component you're adding depends on another versioned component, the CLI resolves it sensibly. An explicit version you pin always takes priority over whatever a dependency would otherwise pull in, so you're never surprised by a version swap you didn't ask for. +- **A new components.json manifest:** Every `buridan add` and `buridan apply` now records what's actually installed in your project, including each component's version and your currently applied theme preset. It's a simple, readable log of your project's setup, not something you need to edit by hand, but useful to have around when you're checking what's installed or diffing changes over time. +- **Clear conflict handling:** If two different versions of the same component end up requested at once, the CLI warns you about it instead of quietly picking one and leaving you to find out later. + +None of this changes how components look or behave today. It just means your project can move at its own pace as the registry evolves underneath it. + # July 2026 - Native/Buridan Under-the-hood layouts can get incredibly messy when they are wrapped in too many heavy React abstractions. In this update, we went back to the drawing board and completely rewrote Buridan's core elements to rely purely on native HTML elements (`rx.el.*`) instead of wrapping everything in heavy, custom third-party components. -By ditching complex client-side libraries like Radix UI and Base UI, we've stripped away massive JavaScript bundles and unnecessary DOM nodes. +By ditching complex client-side libraries like Radix UI and Base UI, we've stripped away massive JavaScript bundles and unnecessary DOM nodes. We did this for a couple of really practical reasons that make a huge difference in day-to-day development: @@ -21,4 +34,4 @@ We did this for a couple of really practical reasons that make a huge difference - **Featherlight DOM Overhead:** Stripping out nested wrapper divs means the browser has fewer nodes to paint. Your rendered page markup is incredibly clean, which makes styling adjustments with Tailwind CSS utilities extremely predictable—no more fighting arbitrary class specificity clashes. - **Predictable 1:1 API mapping:** Native tags are standard. There are no hidden proprietary parameters, undocumented properties, or unexpected behavioral overrides. What you write in your Python code maps 1:1 with what the browser actually renders in the DOM tree. -It’s a simpler, much more robust foundation that keeps your apps lightweight, super-fast, and incredibly responsive. +It's a simpler, much more robust foundation that keeps your apps lightweight, super-fast, and incredibly responsive. diff --git a/docs/getting_started/cli.md b/docs/getting_started/cli.md index 4a81922..ee3d986 100644 --- a/docs/getting_started/cli.md +++ b/docs/getting_started/cli.md @@ -34,7 +34,7 @@ buridan init # apply -Apply a theme preset to your project. Generates `:root` and `.dark` CSS variable blocks in `assets/globals.css` based on the preset ID from the theme builder. +Apply a theme preset to your project. Generates `:root` and `.dark` CSS variable blocks in `assets/globals.css` based on the preset ID from the theme builder, and records the applied theme in [`components.json`](/docs/components-json). ```bash buridan apply --preset @@ -51,6 +51,8 @@ buridan apply --preset b0 buridan apply --preset b2D0wqNxT ``` +Re-running `buridan apply` with a different preset overwrites the theme section of `components.json` and the generated CSS variables — it does not touch any components you've already added. + # add Add components and their dependencies to your project. @@ -71,13 +73,42 @@ Blocks (charts, dashboards, etc.) can be added the same way: buridan add line_chart_01 ``` -Components are placed in `components/`, blocks in `blocks/`. Dependencies are resolved and added automatically. +Components are placed in `components/`, blocks in `blocks/`. Dependencies are resolved and added automatically. Every install updates the `components` section of [`components.json`](/docs/components-json) with the name and version of each installed component. > **Note:** Components require a theme to render correctly. Run `buridan apply` before using components. +## Versioning + +Some components have more than one published version. By default, `add` installs the latest available version: + +```bash +buridan add button +``` + +To pin a specific version, append `@` to the component name: + +```bash +buridan add button@1.0.0 +``` + +Version pins you specify explicitly always take priority over versions pulled in automatically by another component's dependencies. For example, if you run: + +```bash +buridan add card button@1.0.0 +``` + +and `card` normally depends on the latest `button`, your explicit `button@1.0.0` pin wins — `card` will be installed against `button@1.0.0`, not whatever the latest version happens to be. + +If you pin the same component to two different versions in one command (or the CLI otherwise detects a genuine conflict it can't resolve), it prints a warning and keeps the first version it resolved: + +```bash +buridan add button@1.0.0 button@2.0.0 +# Warning: 'button' requested at both 1.0.0 and 2.0.0; keeping 1.0.0. +``` + # list -Display all available components and blocks. +Display all available components and blocks. If a component has multiple published versions, each version is listed separately (e.g. `button@1.0.0`, `button@2.0.0`). ```bash buridan list diff --git a/docs/getting_started/components-json.md b/docs/getting_started/components-json.md new file mode 100644 index 0000000..05cb64c --- /dev/null +++ b/docs/getting_started/components-json.md @@ -0,0 +1,111 @@ +--- +title: "components.json" +description: "Configuration and state tracked automatically for your project." +order: 4 +--- + +# components.json + +The `components.json` file tracks the components and theme currently installed in your project. + +Unlike some other CLI tools, you don't create or edit this file by hand, the `buridan` CLI creates and updates it automatically the first time you run `buridan apply` or `buridan add`. Its main purpose is to give you (and the CLI) a single place to see exactly what's installed, at what version, and under what theme. + +**Note:** `components.json` is written to the root of your Reflex project, alongside `rxconfig.py`. It's safe to commit to version control, treat it like a lockfile for your design system. + +# Structure + +```json +{ + "components": { + "button": "1.0.0", + "card": "latest", + "core": "latest" + }, + "theme": { + "preset": "b0", + "baseId": "neutral", + "colorId": "blue", + "chartId": "blue", + "styleId": "default", + "fontId": "inter", + "radius": "0.5rem" + } +} +``` + +The two sections are updated independently, running `buridan add` never touches `theme`, and running `buridan apply` never touches `components`. + +# components + +A map of installed component names to the version installed. + +```json +{ + "components": { + "button": "1.0.0" + } +} +``` + +- The key is the component's registry name, without any `@version` suffix. +- The value is either a specific version string (e.g. `"1.0.0"`) if you pinned one with `buridan add button@1.0.0`, or the literal string `"latest"` if you installed it unpinned, or if the component doesn't have multiple published versions. + +This section is updated every time you run `buridan add`. See the [CLI docs](/docs/getting-started/cli#versioning) for how version pinning and conflicts are resolved. + +# theme + +Records the theme preset currently applied to your project. + +```json +{ + "theme": { + "preset": "b0", + "baseId": "neutral", + "colorId": "blue", + "chartId": "blue", + "styleId": "default", + "fontId": "inter", + "radius": "0.5rem" + } +} +``` + +This section is written by `buridan apply --preset ` and fully overwritten (not merged) on every subsequent `apply`, it always reflects only the most recently applied preset. + +### theme.preset + +The raw preset ID from the [theme builder](/docs/getting-started/cli#create), exactly as passed to `--preset`. This is the source of truth for the rest of the fields below, since it fully encodes the theme, it's the most reliable value to reference if you ever need to reconstruct or re-apply the same theme programmatically. + +```json +{ + "theme": { + "preset": "b2D0wqNxT" + } +} +``` + +### theme.baseId + +The base theme (background, foreground, and neutral tones) the preset is built on. + +### theme.colorId + +The accent color palette applied on top of the base theme. + +### theme.chartId + +The color palette used for chart-specific CSS variables (`--chart-1` through `--chart-5`, etc.). This can differ from `colorId` if the preset uses a separate chart palette. + +### theme.styleId + +The component style variant (e.g. spacing, shadow, and border conventions) the preset applies. + +### theme.fontId + +The font family applied by the preset. + +### theme.radius + +The border-radius value applied by the preset, e.g. `"0.5rem"`. Omitted from `components.json` if the preset doesn't override the style's own default radius. + +Any field above that wasn't set by a given preset is left out of `components.json` entirely rather than written as `null`, so the file only ever shows what was actually specified. diff --git a/native/engine/generator.py b/native/engine/generator.py index 9f56b69..301beae 100644 --- a/native/engine/generator.py +++ b/native/engine/generator.py @@ -69,11 +69,9 @@ def generate_docs_library() -> list[constants.DocDataStruct]: } ) - # Create the doc data structure using the new clean root function doc = constants.DocDataStruct( url=url_path, description=md_data.get("description", ""), - # 2. Call the function cleanly without an instance lookup wrapper component=parse_and_render(md_content), table_of_content=toc_data, ) diff --git a/native/pages/landing.py b/native/pages/landing.py index 2d3af98..d17e962 100644 --- a/native/pages/landing.py +++ b/native/pages/landing.py @@ -28,7 +28,10 @@ href="/docs", ), a( - button("Build Your Own", hi("ArrowRight02Icon", class_name="size-4")), + button( + "Build Your Own", + # hi("ArrowRight02Icon", class_name="size-4"), + ), href="/create", ), ], diff --git a/native/templates/navbar.py b/native/templates/navbar.py index b830d49..04ec23d 100644 --- a/native/templates/navbar.py +++ b/native/templates/navbar.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from reflex.event import call_script -from reflex_components_core.el import Div, Header, a, div, header +from reflex_components_core.el import Div, Header, a, div, header, p import native.registry.routes as routes from components.core.hugeicon import hi @@ -92,8 +92,19 @@ def navbar(class_name: str = "") -> Header: theme_toggle_button(), _separator(), github(), - _separator(), - a(button("New Project"), href="/create"), + div( + _separator(), + class_name="hidden lg:flex", + ), + a( + button( + hi("GitBranchIcon", class_name="shrink-0 size-4"), + p("buridan@0.0.2", class_name="font-medium"), + variant="ghost", + ), + href="https://pypi.org/project/buridan/", + class_name="hidden lg:flex", + ), class_name="flex flex-row gap-x-2 items-center", ), class_name="w-full mx-auto flex flex-row items-center justify-between px-4 md:px-8 " diff --git a/scripts/generate_registry.py b/scripts/generate_registry.py index ad8f2c1..aaaf288 100644 --- a/scripts/generate_registry.py +++ b/scripts/generate_registry.py @@ -5,33 +5,6 @@ Regenerates `registry/components.py` (COMPONENT_REGISTRY) by scanning the actual source tree and parsing real import statements -- instead of hand-maintaining the dependency dict. - -Why this approach: - Your folder structure keeps changing (e.g. twmerge.py / component.py / - base_ui.py / others.py all got merged into a single core.py per package). - Rather than re-editing a dict by hand every time that happens, this - script treats each *file* as a component, and derives its dependencies - by literally parsing the `import` statements in that file with Python's - `ast` module. If a file imports from another file that is also a known - component, that becomes a dependency edge. No guessing, no drift. - -Usage: - python scripts/generate_registry.py - python scripts/generate_registry.py --check # CI mode: no write, exit 1 on diff - python scripts/generate_registry.py --roots components app/www/library/blocks - python scripts/generate_registry.py --out registry/components.py - -Assumptions (tell me if any of these are wrong and I'll adjust): - - Each component == one .py file (excluding __init__.py) under one of - the scanned root folders. - - The component's registry "name" is the file's stem (e.g. button.py -> "button"). - If you ever have duplicate stems across folders, the script will - warn and disambiguate them (see `_unique_name`). - - Dependencies are discovered from relative imports (`from .core import X`, - `from ..icons.hugeicon import hi`) and from absolute imports that - resolve inside one of the scanned roots (`from components.ui.button import button`). - - External/third-party imports (reflex, typing, etc.) are ignored -- - they're not part of your internal dependency graph. """ from __future__ import annotations @@ -59,8 +32,6 @@ class ComponentFile: def discover_files(repo_root: Path, roots: list[str]) -> list[Path]: - """Find every .py file under the given roots, skipping __init__.py, - __pycache__, and macOS junk.""" files: list[Path] = [] for root in roots: root_path = repo_root / root @@ -77,8 +48,6 @@ def discover_files(repo_root: Path, roots: list[str]) -> list[Path]: def _unique_name(stem: str, rel_path: str, seen: dict[str, str]) -> str: - """Return a registry-safe unique name for this file. Warns on collision - and disambiguates using the parent folder name.""" if stem not in seen: seen[stem] = rel_path return stem @@ -94,8 +63,6 @@ def _unique_name(stem: str, rel_path: str, seen: dict[str, str]) -> str: def build_component_index( repo_root: Path, files: list[Path] ) -> dict[str, ComponentFile]: - """Create the name -> ComponentFile map, and a dotted-module lookup - table used later to resolve imports to components.""" by_name: dict[str, ComponentFile] = {} seen_stems: dict[str, str] = {} @@ -103,17 +70,15 @@ def build_component_index( rel = path.relative_to(repo_root) rel_posix = rel.as_posix() stem = path.stem - - # Check if the file is a versioned file (e.g. v1, v1_0, v2_0_0) - # under a component directory. + if re.match(r"^v\d+(?:_\d+)*$", stem) and len(path.parent.name) > 0: component_name = path.parent.name version = stem[1:].replace("_", ".") - name = f"{component_name}@{version}" + name = _unique_name(f"{component_name}@{version}", rel_posix, seen_stems) else: name = _unique_name(stem, rel_posix, seen_stems) - module_parts = rel.with_suffix("").parts # e.g. ('components','ui','button') + module_parts = rel.with_suffix("").parts dotted_module = ".".join(module_parts) package_dotted = ".".join(module_parts[:-1]) group = "/".join(rel.parts[:-1]) or "." @@ -131,9 +96,6 @@ def build_component_index( def resolve_relative_module(package_dotted: str, level: int, module: str | None) -> str: - """Mimic Python's own relative-import resolution. - level=1 -> current package. level=2 -> parent package. etc. - """ parts = package_dotted.split(".") if package_dotted else [] if level > 1: parts = parts[: len(parts) - (level - 1)] @@ -143,7 +105,6 @@ def resolve_relative_module(package_dotted: str, level: int, module: str | None) def find_dependencies(comp: ComponentFile, module_lookup: dict[str, str]) -> set[str]: - """Parse the file's AST and match imports against known components.""" deps: set[str] = set() try: tree = ast.parse(comp.path.read_text(encoding="utf-8"), filename=str(comp.path)) @@ -160,12 +121,9 @@ def find_dependencies(comp: ComponentFile, module_lookup: dict[str, str]) -> set else: base = node.module or "" - # Case 1: `from .core import cn` -> base itself is the target module if base in module_lookup and module_lookup[base] != comp.name: deps.add(module_lookup[base]) - # Case 2: `from . import button` / `from components.ui import button` - # -> the imported *name* is actually a submodule for alias in node.names: candidate = f"{base}.{alias.name}" if base else alias.name if candidate in module_lookup and module_lookup[candidate] != comp.name: @@ -182,6 +140,80 @@ def find_dependencies(comp: ComponentFile, module_lookup: dict[str, str]) -> set return deps +def find_package_aliases( + repo_root: Path, roots: list[str], module_lookup: dict[str, str] +) -> dict[str, str]: + """Versioned components (e.g. a `button/` folder containing v1.py/v2.py) + need an `__init__.py` that re-exports one version, e.g.: + + # components/ui/button/__init__.py + from .v2 import button + + so that sibling files can keep doing `from .button import button` the + normal Python way. That package-level dotted path + (`components.ui.button`) never appears in `module_lookup` on its own -- + only the versioned files do (`components.ui.button.v1`, `.v2`) -- because + __init__.py files are excluded from being components themselves. + + This scans __init__.py files, follows their own re-export imports, and + registers an alias: package dotted path -> whichever versioned + component that package's __init__.py actually re-exports. That lets + find_dependencies() understand `from .button import button` correctly, + instead of silently missing the edge. + """ + aliases: dict[str, str] = {} + + for root in roots: + root_path = repo_root / root + if not root_path.exists(): + continue + + for init_path in sorted(root_path.rglob("__init__.py")): + rel = init_path.relative_to(repo_root) + module_parts = rel.with_suffix("").parts # (..., "button", "__init__") + package_dotted = ".".join(module_parts[:-1]) + if not package_dotted: + continue + + try: + tree = ast.parse( + init_path.read_text(encoding="utf-8"), filename=str(init_path) + ) + except SyntaxError as e: + print( + f" WARNING: could not parse {rel.as_posix()}: {e}", + file=sys.stderr, + ) + continue + + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + + if node.level and node.level > 0: + base = resolve_relative_module( + package_dotted, node.level, node.module + ) + else: + base = node.module or "" + + if base not in module_lookup: + continue + + target = module_lookup[base] + if package_dotted in aliases and aliases[package_dotted] != target: + print( + f" WARNING: {rel.as_posix()} appears to re-export more than " + f"one version ({aliases[package_dotted]} and {target}); " + f"keeping '{aliases[package_dotted]}'.", + file=sys.stderr, + ) + continue + aliases[package_dotted] = target + + return aliases + + def build_registry(repo_root: Path, roots: list[str]) -> dict[str, ComponentFile]: files = discover_files(repo_root, roots) if not files: @@ -193,6 +225,18 @@ def build_registry(repo_root: Path, roots: list[str]) -> dict[str, ComponentFile by_name = build_component_index(repo_root, files) module_lookup = {c.dotted_module: c.name for c in by_name.values()} + aliases = find_package_aliases(repo_root, roots, module_lookup) + for package_dotted, target in aliases.items(): + if package_dotted in module_lookup and module_lookup[package_dotted] != target: + print( + f" WARNING: '{package_dotted}' is both a real component " + f"({module_lookup[package_dotted]}) and an __init__.py re-export " + f"alias ({target}); keeping the real component.", + file=sys.stderr, + ) + continue + module_lookup[package_dotted] = target + for comp in by_name.values(): comp.dependencies = find_dependencies(comp, module_lookup) @@ -200,7 +244,6 @@ def build_registry(repo_root: Path, roots: list[str]) -> dict[str, ComponentFile def render_registry(by_name: dict[str, ComponentFile]) -> str: - """Render COMPONENT_REGISTRY as nicely grouped, deterministic Python source.""" groups: dict[str, list[ComponentFile]] = {} for comp in by_name.values(): groups.setdefault(comp.group, []).append(comp) @@ -236,25 +279,11 @@ def main() -> None: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) - parser.add_argument( - "--roots", - nargs="+", - default=DEFAULT_ROOTS, - help=f"Folders (relative to repo root) to scan for components. Default: {DEFAULT_ROOTS}", - ) - parser.add_argument( - "--out", - default=DEFAULT_OUT, - help=f"Output path for the generated registry (relative to repo root). Default: {DEFAULT_OUT}", - ) - parser.add_argument( - "--check", - action="store_true", - help="Don't write the file. Exit with code 1 if the generated content would differ from what's on disk.", - ) + parser.add_argument("--roots", nargs="+", default=DEFAULT_ROOTS) + parser.add_argument("--out", default=DEFAULT_OUT) + parser.add_argument("--check", action="store_true") args = parser.parse_args() - # repo root = parent of this script's `scripts/` folder repo_root = Path(__file__).resolve().parent.parent print( From e71f3e7856b27fcc0010c33f1cb1d2cc51891875 Mon Sep 17 00:00:00 2001 From: Ahmad Hakim Date: Sun, 9 Aug 2026 15:31:21 +0200 Subject: [PATCH 3/3] bump buridan package version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9815b3a..71a2ff8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "buridan" -version = "0.0.2" +version = "0.0.3" description = "A command line interface (CLI) for adding components and themes to your project." requires-python = ">=3.11" dependencies = []