htmui 0.1.0__py3-none-any.whl

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.
htmui/basecoat/tabs.py ADDED
@@ -0,0 +1,109 @@
1
+ from typing import Literal, TypeAlias
2
+
3
+ from htmy import ComponentSequence, ComponentType, PropertyValue, SafeStr, html, join_classes
4
+
5
+ __version__ = "0.2.0"
6
+ __framework__ = "BasecoatUI"
7
+ __framework_version__ = "1"
8
+ __framework_url__ = "https://basecoatui.com/components/tabs/"
9
+
10
+ js = SafeStr(
11
+ '<script src="https://cdn.jsdelivr.net/npm/basecoat-css@1/dist/js/tabs.min.js" defer></script>'
12
+ )
13
+
14
+ Orientation: TypeAlias = Literal["horizontal", "vertical"]
15
+
16
+ TabsVariant: TypeAlias = Literal["line"]
17
+
18
+
19
+ def tab_button(
20
+ *children: ComponentType,
21
+ id: str,
22
+ panel_id: str,
23
+ selected: bool = False,
24
+ class_: str | None = None,
25
+ **kwargs: PropertyValue,
26
+ ) -> ComponentType:
27
+ """
28
+ Tab button.
29
+
30
+ Arguments:
31
+ *children: Button content.
32
+ id: Button ID.
33
+ panel_id: The ID of the controlled tab panel.
34
+ selected: Whether this tab is active.
35
+ class_: Extra CSS classes for the button.
36
+ **kwargs: Extra attributes for the button.
37
+ """
38
+ return html.button(
39
+ *children,
40
+ type="button",
41
+ role="tab",
42
+ id=id,
43
+ aria_controls=panel_id,
44
+ aria_selected="true" if selected else "false",
45
+ tabindex="0" if selected else "-1",
46
+ class_=class_,
47
+ **kwargs,
48
+ )
49
+
50
+
51
+ def tab_panel(
52
+ *children: ComponentType,
53
+ id: str,
54
+ button_id: str,
55
+ selected: bool = False,
56
+ class_: str | None = None,
57
+ **kwargs: PropertyValue,
58
+ ) -> ComponentType:
59
+ """
60
+ Tab panel.
61
+
62
+ Arguments:
63
+ *children: Panel content.
64
+ id: Panel ID.
65
+ button_id: The ID of the button that controls this tab panel.
66
+ selected: Whether this panel is active.
67
+ class_: Extra CSS classes for the panel.
68
+ **kwargs: Extra attributes for the panel.
69
+ """
70
+ if not selected:
71
+ kwargs["hidden"] = ""
72
+
73
+ return html.div(
74
+ *children,
75
+ role="tabpanel",
76
+ id=id,
77
+ aria_labelledby=button_id,
78
+ tabindex="-1",
79
+ aria_selected="true" if selected else "false",
80
+ class_=class_,
81
+ **kwargs,
82
+ )
83
+
84
+
85
+ def tabs(
86
+ *panels: ComponentType,
87
+ buttons: ComponentSequence,
88
+ orientation: Orientation = "horizontal",
89
+ variant: TabsVariant | None = None,
90
+ class_: str | None = None,
91
+ **kwargs: PropertyValue,
92
+ ) -> ComponentType:
93
+ """
94
+ Tabs root with a tablist and panels.
95
+
96
+ Arguments:
97
+ *panels: Tab panels.
98
+ buttons: Sequence of tab buttons.
99
+ orientation: Tablist orientation.
100
+ variant: Optional tablist style.
101
+ class_: Extra CSS classes for the root element.
102
+ **kwargs: Extra attributes for the root element.
103
+ """
104
+ return html.div(
105
+ html.nav(*buttons, role="tablist", aria_orientation=orientation, data_variant=variant),
106
+ *panels,
107
+ class_=join_classes("tabs", class_),
108
+ **kwargs,
109
+ )
@@ -0,0 +1,58 @@
1
+ from htmy import ComponentType, SafeStr, html
2
+
3
+ __version__ = "0.2.0"
4
+ __framework__ = "BasecoatUI"
5
+ __framework_version__ = "1"
6
+ __framework_url__ = "https://basecoatui.com/components/theme-switcher/"
7
+
8
+
9
+ light_icon = SafeStr(
10
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
11
+ 'stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" '
12
+ 'd="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 '
13
+ "18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 "
14
+ '12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" /></svg>'
15
+ )
16
+ """`sun` icon from https://heroicons.com/."""
17
+
18
+ dark_icon = SafeStr(
19
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
20
+ 'stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" '
21
+ 'd="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 '
22
+ "0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 "
23
+ '12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" /></svg>'
24
+ )
25
+ """`moon` icon from https://heroicons.com/."""
26
+
27
+ js = SafeStr(
28
+ """<script>
29
+ (() => {
30
+ try {
31
+ const stored = localStorage.getItem("themeMode");
32
+ if (stored ? stored === "dark" : matchMedia("(prefers-color-scheme: dark)").matches) {
33
+ document.documentElement.classList.add("dark");
34
+ }
35
+ } catch (_) {}
36
+ })();
37
+ </script>"""
38
+ )
39
+
40
+
41
+ def theme_switcher(
42
+ *,
43
+ class_: str = "btn size-8",
44
+ variant: str = "outline",
45
+ size: str = "icon",
46
+ ) -> ComponentType:
47
+ return html.button(
48
+ html.span(light_icon, class_="hidden dark:block"),
49
+ html.span(dark_icon, class_="block dark:hidden"),
50
+ type="button",
51
+ aria_label="Toggle theme",
52
+ class_=class_,
53
+ data_tooltip="Toggle theme",
54
+ data_side="bottom",
55
+ data_size=size,
56
+ data_variant=variant,
57
+ onclick="window.basecoat.theme.toggle()",
58
+ )
@@ -0,0 +1,97 @@
1
+ from typing import Literal
2
+
3
+ from htmy import ComponentType, Fragment, PropertyValue, SafeStr, html, join_classes
4
+
5
+ from .typing import Align
6
+
7
+ __version__ = "0.2.0"
8
+ __framework__ = "BasecoatUI"
9
+ __framework_version__ = "1"
10
+ __framework_url__ = "https://basecoatui.com/components/toast/"
11
+
12
+ ToastCategory = Literal["success", "info", "warning", "error"]
13
+
14
+ js = SafeStr(
15
+ '<script src="https://cdn.jsdelivr.net/npm/basecoat-css@1/dist/js/toast.min.js" defer></script>'
16
+ )
17
+
18
+
19
+ def toaster(
20
+ *children: ComponentType,
21
+ id: str,
22
+ class_: str | None = None,
23
+ align: Align | None = None,
24
+ **kwargs: PropertyValue,
25
+ ) -> ComponentType:
26
+ """
27
+ Toast container.
28
+
29
+ Arguments:
30
+ *children: Initial toasts rendered inside the container.
31
+ id: The unique identifier of the component.
32
+ class_: Extra CSS classes for the container.
33
+ align: Horizontal alignment of the toaster.
34
+ **kwargs: Extra attributes for the root element.
35
+ """
36
+ if align is not None:
37
+ kwargs["data-align"] = align
38
+
39
+ return html.div(*children, class_=join_classes("toaster", class_), id=id, **kwargs)
40
+
41
+
42
+ def toast(
43
+ *children: ComponentType,
44
+ category: ToastCategory | None = None,
45
+ class_: str | None = None,
46
+ duration: int | None = None,
47
+ **kwargs: PropertyValue,
48
+ ) -> ComponentType:
49
+ """
50
+ Toast item.
51
+
52
+ Arguments:
53
+ *children: Toast content. Use `toast_content()` for the standard toast layout.
54
+ category: Semantic category that controls styling.
55
+ class_: Optional CSS classes for the toast.
56
+ duration: Timeout in milliseconds. Use `-1` to keep the toast open.
57
+ **kwargs: Extra attributes for the root element.
58
+ """
59
+ if category is not None:
60
+ kwargs["data-category"] = category
61
+ if duration is not None:
62
+ kwargs["data-duration"] = duration
63
+
64
+ return html.div(
65
+ html.div(*children, class_="toast-content"),
66
+ class_=join_classes("toast", class_),
67
+ role="alert" if category == "error" else "status",
68
+ aria_atomic="true",
69
+ aria_hidden="false",
70
+ **kwargs,
71
+ )
72
+
73
+
74
+ def toast_content(
75
+ description: ComponentType,
76
+ *,
77
+ title: ComponentType | None = None,
78
+ icon: ComponentType | None = None,
79
+ footer: ComponentType | None = None,
80
+ ) -> ComponentType:
81
+ """
82
+ Standard toast content layout.
83
+
84
+ Arguments:
85
+ description: Toast description.
86
+ title: Optional toast title.
87
+ icon: Optional icon.
88
+ footer: Optional footer element, typically an `html.footer(...)`.
89
+ """
90
+ return Fragment(
91
+ icon,
92
+ html.section(
93
+ None if title is None else html.h2(title),
94
+ html.p(description),
95
+ ),
96
+ footer,
97
+ )
@@ -0,0 +1,21 @@
1
+ from typing import Literal, Protocol, TypeAlias
2
+
3
+ from htmy import PropertyValue
4
+
5
+ Align: TypeAlias = Literal["start", "center", "end"]
6
+
7
+ HorizontalSide: TypeAlias = Literal["left", "right"]
8
+
9
+ Side: TypeAlias = HorizontalSide | Literal["top", "bottom"]
10
+
11
+ InlineSide: TypeAlias = Literal["inline-start", "inline-end"]
12
+
13
+ FloatingSide: TypeAlias = Side | InlineSide
14
+
15
+
16
+ class IdToProperty(Protocol):
17
+ """Callable protocol for building a property value from an HTML element ID."""
18
+
19
+ def __call__(self, id: str, /) -> PropertyValue:
20
+ """Returns the property value for the given ID."""
21
+ ...
htmui/cli.py ADDED
@@ -0,0 +1,257 @@
1
+ """htmui command-line interface."""
2
+
3
+ import ast
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated, Literal, TypeAlias
8
+
9
+ import typer
10
+
11
+ SOURCE_DIR = Path(__file__).parent / "basecoat"
12
+ UTILITIES = {"cdn", "typing"}
13
+ INIT_STEM = "__init__"
14
+
15
+ _Action: TypeAlias = Literal["new", "overwrite", "skip", "prompt"]
16
+
17
+ app = typer.Typer(name="htmui", no_args_is_help=True)
18
+
19
+
20
+ @app.command()
21
+ def init(
22
+ src: Annotated[
23
+ bool, typer.Option("--src", help="Install under src/<package>/ instead of <package>/.")
24
+ ] = False,
25
+ package: Annotated[
26
+ str, typer.Option("-p", "--package", help="Target package directory name.")
27
+ ] = "components",
28
+ components: Annotated[
29
+ list[str] | None, typer.Option("-c", "--component", help="Component to install (repeatable).")
30
+ ] = None,
31
+ force: Annotated[
32
+ bool, typer.Option("-f", "--force", help="Overwrite existing files without asking.")
33
+ ] = False,
34
+ skip_existing: Annotated[
35
+ bool, typer.Option("--skip-existing", help="Skip existing files without asking.")
36
+ ] = False,
37
+ dry_run: Annotated[
38
+ bool, typer.Option("--dry-run", help="Show what would be done; write nothing.")
39
+ ] = False,
40
+ ) -> None:
41
+ """
42
+ Install basecoat components into the current project.
43
+
44
+ Copies the selected components, or the full catalog if none are given, plus
45
+ their dependencies and shared utilities. Existing `__init__.py` is never
46
+ overwritten.
47
+ """
48
+ if force and skip_existing:
49
+ typer.echo("Error: --force and --skip-existing are mutually exclusive.", err=True)
50
+ raise typer.Exit(1)
51
+
52
+ catalog = _selectable_catalog()
53
+ catalog_set = set(catalog)
54
+
55
+ requested = list(dict.fromkeys([] if components is None else components))
56
+ unknown = [c for c in requested if c not in catalog_set]
57
+ if len(unknown) > 0:
58
+ typer.echo(f"Error: unknown component(s): {', '.join(unknown)}", err=True)
59
+ typer.echo(f"Available components: {', '.join(catalog)}", err=True)
60
+ raise typer.Exit(1)
61
+
62
+ base = (Path.cwd() / "src" / package) if src else (Path.cwd() / package)
63
+ if base.exists() and base.is_file():
64
+ typer.echo(f"Error: {base} exists and is a file, not a directory.", err=True)
65
+ raise typer.Exit(1)
66
+
67
+ selection = requested if len(requested) > 0 else list(catalog)
68
+ resolved, added = _resolve(selection, catalog_set)
69
+ for dep, dependant in added:
70
+ typer.echo(f"+ {dep} (dependency of {dependant})")
71
+
72
+ plans = _plan_install(resolved, base, force=force, skip_existing=skip_existing)
73
+
74
+ if dry_run:
75
+ _print_plan(base, plans)
76
+ raise typer.Exit(0)
77
+
78
+ prompt_count = sum(1 for _s, _d, action in plans if action == "prompt")
79
+ if prompt_count > 0:
80
+ if not sys.stdin.isatty():
81
+ typer.echo(
82
+ f"Error: {prompt_count} existing file(s) in {base}; pass --force or --skip-existing.",
83
+ err=True,
84
+ )
85
+ raise typer.Exit(1)
86
+ choice: _Action = (
87
+ "overwrite"
88
+ if typer.confirm(f"Overwrite {prompt_count} existing file(s) in {base}?")
89
+ else "skip"
90
+ )
91
+ plans = [(source, dest, choice if action == "prompt" else action) for source, dest, action in plans]
92
+
93
+ installed, overwritten, skipped = _write_plans(plans, base)
94
+ typer.echo(f"installed {installed}, overwritten {overwritten}, skipped {skipped} in {base}")
95
+
96
+
97
+ @app.command()
98
+ def version() -> None:
99
+ """Print the htmui version and per-component versions."""
100
+ from htmui import __version__ as htmui_version
101
+
102
+ typer.echo(f"htmui {htmui_version}")
103
+ for name, ver, framework, fw_ver in _component_versions():
104
+ line = f" {name} {ver}"
105
+ if len(framework) > 0:
106
+ line += f" [{framework} {fw_ver}]" if len(fw_ver) > 0 else f" [{framework}]"
107
+ typer.echo(line)
108
+
109
+
110
+ def _selectable_catalog() -> list[str]:
111
+ """Sorted installable component names, excluding `__init__` and utilities."""
112
+ return sorted(
113
+ p.stem for p in SOURCE_DIR.glob("*.py") if p.stem != INIT_STEM and p.stem not in UTILITIES
114
+ )
115
+
116
+
117
+ def _sibling_imports(path: Path, catalog: set[str]) -> set[str]:
118
+ """Relative-import modules from `path` that are in `catalog`."""
119
+ tree = ast.parse(path.read_text(), filename=str(path))
120
+ out: set[str] = set()
121
+ for node in ast.walk(tree):
122
+ if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
123
+ out.add(node.module)
124
+ return out & catalog
125
+
126
+
127
+ def _resolve(selection: list[str], catalog: set[str]) -> tuple[list[str], list[tuple[str, str]]]:
128
+ """
129
+ `selection` plus transitive relative-import dependencies within `catalog`.
130
+
131
+ The second item is `(dependency, dependant)` for each name not already in
132
+ `selection`.
133
+ """
134
+ added_log: list[tuple[str, str]] = []
135
+ stack = list(selection)
136
+ seen = set(selection)
137
+ while stack:
138
+ cur = stack.pop()
139
+ for dep in _sibling_imports(SOURCE_DIR / f"{cur}.py", catalog):
140
+ if dep not in seen:
141
+ added_log.append((dep, cur))
142
+ seen.add(dep)
143
+ stack.append(dep)
144
+ return sorted(seen), added_log
145
+
146
+
147
+ def _plan_install(
148
+ resolved: list[str],
149
+ base: Path,
150
+ *,
151
+ force: bool,
152
+ skip_existing: bool,
153
+ ) -> list[tuple[Path, Path, _Action]]:
154
+ """
155
+ Source, destination, and action for installing `resolved` into `base`.
156
+
157
+ Always includes `__init__.py` and the utility modules. An existing
158
+ `__init__.py` is skipped. Other existing files are overwritten if `force`,
159
+ skipped if `skip_existing`, otherwise marked `prompt`.
160
+ """
161
+ plans: list[tuple[Path, Path, _Action]] = []
162
+ for name in [INIT_STEM, *sorted(UTILITIES), *resolved]:
163
+ source = SOURCE_DIR / f"{name}.py"
164
+ dest = base / f"{name}.py"
165
+ if dest.exists() and name == INIT_STEM:
166
+ action: _Action = "skip"
167
+ elif not dest.exists():
168
+ action = "new"
169
+ elif force:
170
+ action = "overwrite"
171
+ elif skip_existing:
172
+ action = "skip"
173
+ else:
174
+ action = "prompt"
175
+ plans.append((source, dest, action))
176
+ return plans
177
+
178
+
179
+ def _action_line(dest: Path, action: _Action) -> str:
180
+ name = dest.name
181
+ if action == "skip" and dest.stem == INIT_STEM:
182
+ return f" skip {name} (existing __init__.py preserved)"
183
+ if action == "new":
184
+ return f" new {name}"
185
+ if action == "overwrite":
186
+ return f" overwrite {name}"
187
+ if action == "skip":
188
+ return f" skip {name}"
189
+ return f" ask {name}"
190
+
191
+
192
+ def _print_plan(base: Path, plans: list[tuple[Path, Path, _Action]]) -> None:
193
+ """Print `plans` and a count summary for `base`."""
194
+ typer.echo(f"Dry-run plan for {base}:")
195
+ would_install = would_overwrite = would_skip = would_ask = 0
196
+ for _source, dest, action in plans:
197
+ typer.echo(_action_line(dest, action))
198
+ if action == "new":
199
+ would_install += 1
200
+ elif action == "overwrite":
201
+ would_overwrite += 1
202
+ elif action == "prompt":
203
+ would_ask += 1
204
+ else:
205
+ would_skip += 1
206
+ typer.echo(
207
+ f"would install {would_install}, overwrite {would_overwrite}, skip {would_skip}, ask {would_ask}"
208
+ )
209
+
210
+
211
+ def _write_plans(plans: list[tuple[Path, Path, _Action]], base: Path) -> tuple[int, int, int]:
212
+ """Write `plans` under `base`. Returns `(installed, overwritten, skipped)`."""
213
+ base.mkdir(parents=True, exist_ok=True)
214
+ installed = 0
215
+ overwritten = 0
216
+ skipped = 0
217
+ for source, dest, action in plans:
218
+ if action in ("new", "overwrite"):
219
+ shutil.copyfile(source, dest)
220
+ if action == "overwrite":
221
+ overwritten += 1
222
+ else:
223
+ installed += 1
224
+ else:
225
+ skipped += 1
226
+ typer.echo(_action_line(dest, action))
227
+ return installed, overwritten, skipped
228
+
229
+
230
+ def _component_versions() -> list[tuple[str, str, str, str]]:
231
+ """
232
+ `(name, version, framework, framework_version)` for each module with `__version__`.
233
+
234
+ Missing framework attributes are empty strings.
235
+ """
236
+ import importlib.util
237
+
238
+ names = sorted(p.stem for p in SOURCE_DIR.glob("*.py") if p.stem != INIT_STEM)
239
+ rows: list[tuple[str, str, str, str]] = []
240
+ for name in names:
241
+ path = SOURCE_DIR / f"{name}.py"
242
+ spec = importlib.util.spec_from_file_location(f"htmui.basecoat.{name}", path)
243
+ if spec is None or spec.loader is None:
244
+ continue
245
+ mod = importlib.util.module_from_spec(spec)
246
+ spec.loader.exec_module(mod)
247
+ ver = getattr(mod, "__version__", None)
248
+ if ver is None:
249
+ continue
250
+ framework = getattr(mod, "__framework__", "")
251
+ fw_ver = getattr(mod, "__framework_version__", "")
252
+ rows.append((name, ver, framework, fw_ver))
253
+ return rows
254
+
255
+
256
+ if __name__ == "__main__":
257
+ app()
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.1
2
+ Name: htmui
3
+ Version: 0.1.0
4
+ Summary: htmy component library
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: fastapi[standard]>=0.136.3
7
+ Requires-Dist: holm>=0.10.0
8
+ Requires-Dist: htmy[lxml]>=0.13.1
9
+ Requires-Dist: pydantic-settings>=2.15.0
10
+ Requires-Dist: typer>=0.27.1
11
+ Requires-Dist: uvicorn>=0.52.1
12
+ Description-Content-Type: text/markdown
13
+
14
+ ![Linters](https://github.com/volfpeter/htmui/actions/workflows/linters.yml/badge.svg)
15
+
16
+ **Source code**: [https://github.com/volfpeter/htmui](https://github.com/volfpeter/htmui)
17
+
18
+ **Live demo**: [https://htmui.vercel.app](https://htmui.vercel.app)
19
+
20
+ # htmui
21
+
22
+ Python UI components for [htmy](https://volfpeter.github.io/htmy/), wrapping [BasecoatUI](https://basecoatui.com/) and [Tailwind CSS](https://tailwindcss.com/).
23
+
24
+ `htmui` is designed with hypermedia applications, and specifically [HTMX](https://htmx.org/), in mind. Components reproduce Basecoat's documented markup so Basecoat's runtime can enhance them.
25
+
26
+ `htmy` and `holm` have built-in Jinja support. If you use another templating engine, converting the components is straightforward, because `htmy` mimics HTML.
27
+
28
+ ## Key features
29
+
30
+ - **Vendored**: copy the components you need into your project and adapt them.
31
+ - **`htmui init` CLI** that copies selected components, their dependencies, and shared utilities.
32
+ - **BasecoatUI** components as typed `htmy` functions: buttons, dialogs, sidebars, forms, and more.
33
+ - Extra helpers such as `codeblock` that are not part of Basecoat.
34
+ - Works anywhere `htmy` works, including [holm](https://volfpeter.github.io/holm/).
35
+
36
+ ## Prerequisites
37
+
38
+ You need [htmy](https://volfpeter.github.io/htmy/) in your project:
39
+
40
+ ```bash
41
+ pip install htmy
42
+ ```
43
+
44
+ For the best developer experience, it is recommended to use [holm](https://volfpeter.github.io/holm/). It brings built-in `htmy` and HTMX support, and is built around FastAPI.
45
+
46
+ The copied components also need [BasecoatUI](https://basecoatui.com/) and [Tailwind CSS](https://tailwindcss.com/) on the page. CDN snippets are included (see [Assets](#assets)).
47
+
48
+ ## Usage
49
+
50
+ `htmui` is designed to be vendored. You do not install it as a runtime dependency. Instead, you copy the components you need into your project and adjust them.
51
+
52
+ ```bash
53
+ uvx htmui init
54
+ ```
55
+
56
+ This copies the full catalog into `./components`. Use `-c` to copy only the components you need. Dependencies are included automatically:
57
+
58
+ ```bash
59
+ uvx htmui init -c dialog -c select
60
+ ```
61
+
62
+ Useful options:
63
+
64
+ ```bash
65
+ uvx htmui init --src # install into src/components/
66
+ uvx htmui init -p ui # install into ./ui
67
+ uvx htmui init -c dialog --dry-run # print the plan, write nothing
68
+ uvx htmui init --force # overwrite existing files
69
+ uvx htmui init --skip-existing # keep existing files
70
+ uvx htmui version # package and per-component versions
71
+ ```
72
+
73
+ You can also copy files from `htmui/basecoat/` by hand.
74
+
75
+ ### Example
76
+
77
+ After `htmui init`:
78
+
79
+ ```python
80
+ from htmy import ComponentType, html
81
+
82
+ from components.alert import alert
83
+ from components.button import button
84
+
85
+
86
+ def saved() -> ComponentType:
87
+ return html.div(
88
+ alert("Your changes have been saved.", title="Saved"),
89
+ button("Continue"),
90
+ )
91
+ ```
92
+
93
+ Every component has a live example and its Python source in the sidebar.
94
+
95
+ ## Assets
96
+
97
+ You can add the CDN snippets from `cdn` to your document head:
98
+
99
+ ```python
100
+ from htmy import ComponentType, html
101
+
102
+ from components import cdn
103
+
104
+
105
+ def head() -> ComponentType:
106
+ return html.head(
107
+ cdn.css,
108
+ cdn.tailwind_css,
109
+ cdn.js,
110
+ )
111
+ ```
112
+
113
+ Some Basecoat classes may not work with the Tailwind CDN setup, so a local JS/CSS setup is recommended for production. The [demo application](https://github.com/volfpeter/htmui/tree/main/app) shows one such setup.
114
+
115
+ `codeblock` includes optional [Highlight.js](https://highlightjs.org/) helpers for syntax highlighting.
116
+
117
+ ## Related projects
118
+
119
+ - [htmy](https://volfpeter.github.io/htmy/): async Python server-side rendering.
120
+ - [holm](https://volfpeter.github.io/holm/): Next.js-like developer experience on FastAPI, `htmy`, and FastHX.
121
+ - [FastHX](https://volfpeter.github.io/fasthx/): HTMX rendering for FastAPI.
122
+ - [BasecoatUI](https://basecoatui.com/): the design system these components wrap.
123
+
124
+ ## Support
125
+
126
+ Consider supporting the project through [sponsoring](https://buymeacoffee.com/volfpeter), or reach out for [consulting](https://www.volfp.com/contact?subject=Consulting%20-%20htmui) so you can get the most out of the library.
127
+
128
+ ## Development
129
+
130
+ Python:
131
+
132
+ - `uv` for project and dependency management.
133
+ - `poethepoet` for tasks. Run `uv run poe` to see them.
134
+ - `mypy` for static analysis.
135
+ - `ruff` for formatting and linting.
136
+
137
+ JavaScript:
138
+
139
+ - `npm` for the demo app's Tailwind and Basecoat tooling.
140
+
141
+ To get started:
142
+
143
+ ```bash
144
+ uv sync
145
+ npm install
146
+ honcho start
147
+ ```
148
+
149
+ `honcho start` runs the demo app and the Tailwind watcher together. You can also run `uv run poe dev` and `uv run poe build-dev-css --watch` separately.
150
+
151
+ The index page of the demo is generated from this README (`uv run poe build-content`).
152
+
153
+ ## License
154
+
155
+ The package is open-sourced under the conditions of the [MIT license](https://choosealicense.com/licenses/mit/).
156
+
157
+ ## Credits
158
+
159
+ This project wouldn't exist without the components and documentation of [BasecoatUI](https://basecoatui.com/).