katalyst-plugin-bard 0.1.1a1__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.
Files changed (59) hide show
  1. katalyst_plugin_bard/__init__.py +5 -0
  2. katalyst_plugin_bard/build.py +224 -0
  3. katalyst_plugin_bard/cli.py +234 -0
  4. katalyst_plugin_bard/contrib/build.mjs +65 -0
  5. katalyst_plugin_bard/contrib/shared.json +10 -0
  6. katalyst_plugin_bard/contributions.py +176 -0
  7. katalyst_plugin_bard/plugin.py +44 -0
  8. katalyst_plugin_bard/py.typed +0 -0
  9. katalyst_plugin_bard/schemas/bard.manifest.schema.json +178 -0
  10. katalyst_plugin_bard/serve.py +626 -0
  11. katalyst_plugin_bard/web_dist/desktop/main.cjs +8369 -0
  12. katalyst_plugin_bard/web_dist/desktop/package.json +6 -0
  13. katalyst_plugin_bard/web_dist/server/index.cjs +43202 -0
  14. katalyst_plugin_bard/web_dist/server/index.cjs.map +7 -0
  15. katalyst_plugin_bard/web_dist/web/assets/Chat-CU3UoVyk.js +42 -0
  16. katalyst_plugin_bard/web_dist/web/assets/Chat-CpKl2eGN.css +1 -0
  17. katalyst_plugin_bard/web_dist/web/assets/DataTable-D45zW-iy.js +1 -0
  18. katalyst_plugin_bard/web_dist/web/assets/GraphCanvas-LOIKzHVV.js +331 -0
  19. katalyst_plugin_bard/web_dist/web/assets/KindsView-DYW_khzh.js +1 -0
  20. katalyst_plugin_bard/web_dist/web/assets/NodeDetail-DUwFKFxk.js +1 -0
  21. katalyst_plugin_bard/web_dist/web/assets/NodesView-R6LjlOlL.js +1 -0
  22. katalyst_plugin_bard/web_dist/web/assets/SchemaGraphPanel-CxW6ysWN.js +1 -0
  23. katalyst_plugin_bard/web_dist/web/assets/SettingsView-E-EGyvjS.js +130 -0
  24. katalyst_plugin_bard/web_dist/web/assets/TooltipIconButton-DxN4d6_s.js +1 -0
  25. katalyst_plugin_bard/web_dist/web/assets/TreeNav-BnDYMAw4.js +9373 -0
  26. katalyst_plugin_bard/web_dist/web/assets/WingsView-DvKWpWka.js +1 -0
  27. katalyst_plugin_bard/web_dist/web/assets/_commonjsHelpers-CqkleIqs.js +1 -0
  28. katalyst_plugin_bard/web_dist/web/assets/client-BVWFISPM.js +25 -0
  29. katalyst_plugin_bard/web_dist/web/assets/empty-Ct-3knBG.js +2 -0
  30. katalyst_plugin_bard/web_dist/web/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  31. katalyst_plugin_bard/web_dist/web/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  32. katalyst_plugin_bard/web_dist/web/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  33. katalyst_plugin_bard/web_dist/web/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  34. katalyst_plugin_bard/web_dist/web/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  35. katalyst_plugin_bard/web_dist/web/assets/index-Bn5I7isx.js +1 -0
  36. katalyst_plugin_bard/web_dist/web/assets/index-C2oMlmGY.css +1 -0
  37. katalyst_plugin_bard/web_dist/web/assets/index-CbYvlL0N.js +9 -0
  38. katalyst_plugin_bard/web_dist/web/assets/index-DZ8IcxKl.js +9 -0
  39. katalyst_plugin_bard/web_dist/web/assets/index-DuLQ-QSA.js +2 -0
  40. katalyst_plugin_bard/web_dist/web/assets/index-Iz84zNIC.js +1 -0
  41. katalyst_plugin_bard/web_dist/web/assets/jsx-runtime-l_A6JZ6M.js +9 -0
  42. katalyst_plugin_bard/web_dist/web/assets/nunito-sans-cyrillic-ext-wght-normal-7w74d1lj.woff2 +0 -0
  43. katalyst_plugin_bard/web_dist/web/assets/nunito-sans-cyrillic-wght-normal-B9hoeQUC.woff2 +0 -0
  44. katalyst_plugin_bard/web_dist/web/assets/nunito-sans-latin-ext-wght-normal-DMEAsnHD.woff2 +0 -0
  45. katalyst_plugin_bard/web_dist/web/assets/nunito-sans-latin-wght-normal-BWQ3gi2K.woff2 +0 -0
  46. katalyst_plugin_bard/web_dist/web/assets/nunito-sans-vietnamese-wght-normal-BhFYcaJr.woff2 +0 -0
  47. katalyst_plugin_bard/web_dist/web/assets/shared-katalyst-bard-host-hVOU1c3P.js +1 -0
  48. katalyst_plugin_bard/web_dist/web/assets/shared-react-CBKIYMuS.js +1 -0
  49. katalyst_plugin_bard/web_dist/web/assets/shared-react-dom-EXphnHyn.js +1 -0
  50. katalyst_plugin_bard/web_dist/web/assets/shared-react-dom-client-BZbsmXA9.js +1 -0
  51. katalyst_plugin_bard/web_dist/web/assets/shared-react-jsx-runtime--VkjFgMQ.js +1 -0
  52. katalyst_plugin_bard/web_dist/web/assets/space-grotesk-latin-ext-wght-normal-D9tNdqV9.woff2 +0 -0
  53. katalyst_plugin_bard/web_dist/web/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
  54. katalyst_plugin_bard/web_dist/web/assets/space-grotesk-vietnamese-wght-normal-D0rl6rjA.woff2 +0 -0
  55. katalyst_plugin_bard/web_dist/web/index.html +33 -0
  56. katalyst_plugin_bard-0.1.1a1.dist-info/METADATA +84 -0
  57. katalyst_plugin_bard-0.1.1a1.dist-info/RECORD +59 -0
  58. katalyst_plugin_bard-0.1.1a1.dist-info/WHEEL +4 -0
  59. katalyst_plugin_bard-0.1.1a1.dist-info/entry_points.txt +5 -0
@@ -0,0 +1,5 @@
1
+ """Katalyst bard plugin — schema-driven web UI for the taxonomy."""
2
+
3
+ from .plugin import BardPlugin
4
+
5
+ __all__ = ["BardPlugin"]
@@ -0,0 +1,224 @@
1
+ """Build a contribution: validate its source manifest, bundle it, write the built manifest.
2
+
3
+ ``kata tax bard build-contribution <source> --out <dir>`` is the one build a
4
+ contribution author needs. It turns a source directory (``bard.manifest.json``
5
+ naming ``.ts``/``.tsx`` entries, importing only ``@katalyst/bard-host``) into a
6
+ directory Bard loads at runtime: one ES module per view or slot, its stylesheet,
7
+ and a built manifest naming them. Ship that directory in a Python package and
8
+ declare it under the ``katalyst_bard.contributions`` entry point
9
+ (:mod:`katalyst_plugin_bard.contributions`).
10
+
11
+ The bundling itself is ``contrib/build.mjs`` (esbuild, from the contribution's own
12
+ ``node_modules``). React, react-dom and the host contract are not bundled: the
13
+ host provides them at runtime (``contrib/shared.json``), so there is one React.
14
+
15
+ Validation happens here, before Node runs, so a bad manifest fails with the
16
+ reason rather than as a bundler error far from its cause.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import copy
22
+ import json
23
+ import shutil
24
+ import subprocess
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from katalyst_plugin_bard.contributions import MANIFEST
31
+
32
+ #: The bundling script and the shared-module list it reads, shipped in this package.
33
+ BUILD_SCRIPT = Path(__file__).parent / "contrib" / "build.mjs"
34
+
35
+ #: Predicates the runtime cannot honour yet. Rejected at build time so the failure is
36
+ #: a build error rather than a fragment that renders nowhere. See
37
+ #: katalyst-taxonomy#196 — the REST API does not expose ``spec.extends``.
38
+ UNSUPPORTED_PREDICATES = ("attachedKind",)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class BuiltEntry:
43
+ id: str
44
+ module: str
45
+ styles: tuple[str, ...]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class BuildResult:
50
+ name: str
51
+ out: Path
52
+ entries: tuple[BuiltEntry, ...]
53
+
54
+
55
+ #: ``(script, payload, cwd) -> stdout``. Raises RuntimeError on failure.
56
+ RunNode = Callable[[Path, dict[str, Any], Path], str]
57
+
58
+
59
+ def default_schema_path() -> Path:
60
+ """The schema shipped inside the wheel, falling back to a repo checkout."""
61
+ packaged = Path(__file__).parent / "schemas" / "bard.manifest.schema.json"
62
+ if packaged.is_file():
63
+ return packaged
64
+ # src/katalyst_plugin_bard/build.py -> plugin root is parents[2].
65
+ return Path(__file__).parents[2] / "schemas" / "bard.manifest.schema.json"
66
+
67
+
68
+ def load_schema(schema_path: Path | None = None) -> dict[str, Any]:
69
+ """Load the contribution manifest schema."""
70
+ return json.loads((schema_path or default_schema_path()).read_text())
71
+
72
+
73
+ def _schema_errors(doc: Any, schema: dict[str, Any]) -> list[str]:
74
+ """Validation messages for a document, as plain strings.
75
+
76
+ `jsonschema` ships no type stubs, so this is the single place the untyped
77
+ boundary is crossed — keeping the ignore localised instead of scattered
78
+ through the validation logic.
79
+ """
80
+ from jsonschema import Draft202012Validator
81
+
82
+ validator = Draft202012Validator(schema) # pyright: ignore[reportUnknownMemberType]
83
+ messages: list[str] = []
84
+ for error in validator.iter_errors(doc): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
85
+ path = "/".join(str(p) for p in error.absolute_path) or "(root)" # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
86
+ messages.append(f"{path}: {error.message}") # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
87
+ return messages
88
+
89
+
90
+ def validate_source(source: Path, schema: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
91
+ """Read and validate a source manifest. Returns the document and every problem."""
92
+ manifest = source / MANIFEST
93
+ if not manifest.is_file():
94
+ return {}, [f"no {MANIFEST} in {source}"]
95
+ try:
96
+ doc: dict[str, Any] = json.loads(manifest.read_text())
97
+ except json.JSONDecodeError as exc:
98
+ return {}, [f"invalid JSON: {exc}"]
99
+
100
+ errors = _schema_errors(doc, schema)
101
+ if errors:
102
+ return doc, errors
103
+
104
+ problems: list[str] = []
105
+ name = doc["metadata"]["name"]
106
+ if name != source.resolve().name:
107
+ problems.append(
108
+ f"metadata.name '{name}' does not match directory '{source.resolve().name}'"
109
+ )
110
+
111
+ spec: dict[str, Any] = doc.get("spec", {})
112
+ if "hostRange" not in spec:
113
+ # Bard skips a contribution it cannot check, so building one would only defer
114
+ # the failure to whoever installs it.
115
+ problems.append("spec.hostRange is required: the host versions this contribution supports")
116
+
117
+ entries: list[dict[str, Any]] = [*spec.get("views", ()), *spec.get("slots", ())]
118
+ if not entries:
119
+ problems.append("declares no views or slots — it would contribute nothing")
120
+
121
+ seen: set[str] = set()
122
+ for entry in entries:
123
+ if entry["id"] in seen:
124
+ problems.append(f"id '{entry['id']}' is declared twice")
125
+ seen.add(entry["id"])
126
+ if not (source / entry["entry"]).is_file():
127
+ problems.append(f"entry '{entry['entry']}' (id '{entry['id']}') does not exist")
128
+
129
+ for view in spec.get("views", ()):
130
+ if view.get("placement") != "footer" and "section" not in view and "section" not in spec:
131
+ # Bard refuses a contributed main view with no subsection to list it under.
132
+ problems.append(
133
+ f"view '{view['id']}' declares no section: set \"section\" on the view, or "
134
+ "on spec for all of them — the subsection it is listed under in your "
135
+ "package's sidebar heading"
136
+ )
137
+
138
+ for slot in spec.get("slots", ()):
139
+ applies = slot.get("appliesWhen", {})
140
+ for predicate in UNSUPPORTED_PREDICATES:
141
+ if predicate in applies:
142
+ problems.append(
143
+ f"slot '{slot['id']}' uses appliesWhen.{predicate}, which the runtime "
144
+ "cannot honour yet (the taxonomy API does not expose spec.extends — "
145
+ "see katalyst-taxonomy#196)"
146
+ )
147
+ return doc, problems
148
+
149
+
150
+ def _default_run_node(script: Path, payload: dict[str, Any], cwd: Path) -> str:
151
+ node = shutil.which("node")
152
+ if node is None:
153
+ raise RuntimeError("node is required to build a contribution. Install Node.js 20 or newer.")
154
+ result = subprocess.run(
155
+ [node, str(script), json.dumps(payload)],
156
+ cwd=cwd,
157
+ capture_output=True,
158
+ text=True,
159
+ check=False,
160
+ )
161
+ if result.returncode != 0:
162
+ raise RuntimeError(
163
+ f"bundling failed (node exit {result.returncode}):\n{result.stderr.strip()}"
164
+ )
165
+ return result.stdout
166
+
167
+
168
+ def _prepare_out(out: Path) -> None:
169
+ """Empty *out* for a fresh build, refusing a directory that is not a previous one.
170
+
171
+ The whole directory is replaced so a module renamed in source does not linger in
172
+ the shipped build; a directory with other content is refused rather than wiped.
173
+ """
174
+ if out.exists():
175
+ if not out.is_dir():
176
+ raise RuntimeError(f"--out {out} is not a directory")
177
+ if any(out.iterdir()) and not (out / MANIFEST).is_file():
178
+ raise RuntimeError(
179
+ f"--out {out} is not empty and holds no {MANIFEST}; refusing to replace it"
180
+ )
181
+ shutil.rmtree(out)
182
+ out.mkdir(parents=True)
183
+
184
+
185
+ def build_contribution(
186
+ source: Path,
187
+ out: Path,
188
+ *,
189
+ schema: dict[str, Any] | None = None,
190
+ run_node: RunNode = _default_run_node,
191
+ ) -> BuildResult:
192
+ """Validate *source*, bundle it into *out*, and write the built manifest there."""
193
+ doc, problems = validate_source(source, schema if schema is not None else load_schema())
194
+ if problems:
195
+ raise RuntimeError(
196
+ f"{source / MANIFEST} is not buildable:\n" + "\n".join(f" - {p}" for p in problems)
197
+ )
198
+
199
+ spec: dict[str, Any] = doc["spec"]
200
+ entries = {e["id"]: e["entry"] for e in (*spec.get("views", ()), *spec.get("slots", ()))}
201
+ _prepare_out(out)
202
+ stdout = run_node(
203
+ BUILD_SCRIPT,
204
+ {"root": str(source.resolve()), "out": str(out.resolve()), "entries": entries},
205
+ source.resolve(),
206
+ )
207
+ bundled: dict[str, dict[str, Any]] = json.loads(stdout)
208
+
209
+ missing = sorted(set(entries) - set(bundled))
210
+ if missing:
211
+ raise RuntimeError(f"bundling produced no module for: {', '.join(missing)}")
212
+
213
+ built = copy.deepcopy(doc)
214
+ results: list[BuiltEntry] = []
215
+ for key in ("views", "slots"):
216
+ for entry in built["spec"].get(key, ()):
217
+ output = bundled[entry["id"]]
218
+ entry["entry"] = output["module"]
219
+ styles = tuple(output.get("styles", ()))
220
+ if styles:
221
+ entry["styles"] = list(styles)
222
+ results.append(BuiltEntry(entry["id"], output["module"], styles))
223
+ (out / MANIFEST).write_text(json.dumps(built, indent=2) + "\n")
224
+ return BuildResult(doc["metadata"]["name"], out, tuple(results))
@@ -0,0 +1,234 @@
1
+ """CLI contribution — `kata tax bard <command>`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ import click
9
+
10
+ from katalyst_plugin_bard.contributions import NO_CONTRIBUTIONS_ENV
11
+
12
+
13
+ class _Output(Protocol):
14
+ def echo(self, message: str) -> None: ...
15
+
16
+
17
+ class CliContextLike(Protocol):
18
+ """The slice of the CLI context bard needs.
19
+
20
+ Declared structurally rather than importing ``CliContext``: the concrete
21
+ class lives in the CLI package, and depending on the shape rather than the
22
+ type keeps this plugin loadable without it while staying type-checked.
23
+ """
24
+
25
+ @property
26
+ def output(self) -> _Output: ...
27
+
28
+
29
+ def create_group() -> click.Group:
30
+ """Factory called by CLI discovery to register the bard command group."""
31
+
32
+ @click.group("bard")
33
+ def bard() -> None:
34
+ """Bard — a schema-driven web UI for the taxonomy."""
35
+
36
+ @bard.command("serve")
37
+ @click.option("--port", "ui_port", type=int, default=5273, help="Port for the bard UI.")
38
+ @click.option(
39
+ "--api-port",
40
+ type=int,
41
+ default=None,
42
+ help=(
43
+ "Port for the local REST API when Bard starts one; starting fails if it is in "
44
+ "use. An API already running for your config file is reused on its own port. "
45
+ "Default: a free port."
46
+ ),
47
+ )
48
+ @click.option(
49
+ "--api-url",
50
+ default=None,
51
+ help=(
52
+ "Use an existing taxonomy API instead of starting one locally. "
53
+ "Bard reads all data over HTTP, so this can be any reachable server; "
54
+ "its token is read from KATALYST_API_TOKEN."
55
+ ),
56
+ )
57
+ @click.option("--no-open", is_flag=True, help="Do not open a browser.")
58
+ @click.option(
59
+ "--no-contributions",
60
+ is_flag=True,
61
+ envvar=NO_CONTRIBUTIONS_ENV,
62
+ help=(
63
+ "Plain Bard: load none of the views installed packages contribute, only "
64
+ f"Bard's own. Also set by {NO_CONTRIBUTIONS_ENV}=1."
65
+ ),
66
+ )
67
+ @click.option("--dev", is_flag=True, help="Print dev-server instructions instead of serving.")
68
+ @click.pass_obj
69
+ def serve(
70
+ ctx: CliContextLike,
71
+ ui_port: int,
72
+ api_port: int | None,
73
+ api_url: str | None,
74
+ no_open: bool,
75
+ no_contributions: bool,
76
+ dev: bool,
77
+ ) -> None:
78
+ """Serve the bard UI, ensuring a taxonomy API is reachable.
79
+
80
+ Runs from any directory: the API starts from the user config file, and every
81
+ repository is a filesystem backend declared there. With KATALYST_CONFIG_WRITE=1
82
+ the local API accepts configuration writes (a running one that does not is
83
+ restarted). Requires Node.js 20+ and built assets (`just build` in plugins/bard).
84
+ """
85
+ # Deferred: CLI discovery imports this module on every `kata` invocation,
86
+ # so the heavy import stays inside the command body.
87
+ from katalyst_plugin_bard.serve import BardConfig, guard_teardown, run_bard
88
+
89
+ if dev:
90
+ ctx.output.echo("Dev mode — run the Node server and Vite dev server manually:")
91
+ ctx.output.echo(" cd plugins/bard/web && npm install && npm run dev")
92
+ ctx.output.echo("Set TAXONOMY_API_URL if your API is not on http://127.0.0.1:8000")
93
+ raise SystemExit(0)
94
+
95
+ config = BardConfig(
96
+ ui_port=ui_port,
97
+ api_port=api_port,
98
+ open_browser=not no_open,
99
+ taxonomy_api_url=api_url,
100
+ contributions=not no_contributions,
101
+ )
102
+ try:
103
+ url, handle = run_bard(config, echo=ctx.output.echo)
104
+ except RuntimeError as exc:
105
+ ctx.output.echo(f"Error: {exc}")
106
+ raise SystemExit(1) from exc
107
+
108
+ guard_teardown(handle)
109
+ ctx.output.echo(f"Bard serving at {url}")
110
+ ctx.output.echo("Press Ctrl+C to stop.")
111
+ try:
112
+ code = handle.wait()
113
+ except KeyboardInterrupt:
114
+ handle.terminate()
115
+ code = 0
116
+ raise SystemExit(code)
117
+
118
+ @bard.command("desktop")
119
+ @click.option(
120
+ "--api-url",
121
+ default=None,
122
+ help=(
123
+ "Use an existing taxonomy API instead of starting one locally; its token is "
124
+ "read from KATALYST_API_TOKEN. Settings can save only if that API was started "
125
+ "with KATALYST_CONFIG_WRITE=1."
126
+ ),
127
+ )
128
+ @click.option(
129
+ "--api-port",
130
+ type=int,
131
+ default=None,
132
+ help=(
133
+ "Port for the local REST API when Bard starts one; starting fails if it is in "
134
+ "use. An API already running for your config file is reused on its own port. "
135
+ "Default: a free port."
136
+ ),
137
+ )
138
+ @click.option(
139
+ "--electron",
140
+ "electron",
141
+ type=click.Path(dir_okay=False, path_type=Path),
142
+ default=None,
143
+ help=(
144
+ "Electron executable to use. Default: KATALYST_ELECTRON, else the pinned "
145
+ "version, fetched into the Katalyst cache with npm on first run."
146
+ ),
147
+ )
148
+ @click.option(
149
+ "--no-contributions",
150
+ is_flag=True,
151
+ envvar=NO_CONTRIBUTIONS_ENV,
152
+ help=(
153
+ "Plain Bard: load none of the views installed packages contribute, only "
154
+ f"Bard's own. Also set by {NO_CONTRIBUTIONS_ENV}=1."
155
+ ),
156
+ )
157
+ @click.pass_obj
158
+ def desktop(
159
+ ctx: CliContextLike,
160
+ api_url: str | None,
161
+ api_port: int | None,
162
+ electron: Path | None,
163
+ no_contributions: bool,
164
+ ) -> None:
165
+ """Open bard in a desktop window, with settings editable.
166
+
167
+ The desktop shell serves the UI over a custom protocol rather than a
168
+ local HTTP server, so there is no port to bind and nothing to collide
169
+ with. It starts the taxonomy API for your config file with configuration
170
+ writes enabled. With --api-url it starts nothing, and saving settings
171
+ needs that API to have been started with KATALYST_CONFIG_WRITE=1.
172
+ Requires Node.js 20+ (npm fetches Electron on first run).
173
+ """
174
+ from katalyst_plugin_bard.serve import BardConfig, guard_teardown, run_desktop
175
+
176
+ config = BardConfig(
177
+ ui_port=0, # unused: the desktop shell binds no port
178
+ api_port=api_port,
179
+ open_browser=False,
180
+ taxonomy_api_url=api_url,
181
+ contributions=not no_contributions,
182
+ )
183
+ try:
184
+ handle = run_desktop(config, electron=electron, echo=ctx.output.echo)
185
+ except RuntimeError as exc:
186
+ ctx.output.echo(f"Error: {exc}")
187
+ raise SystemExit(1) from exc
188
+
189
+ guard_teardown(handle)
190
+ ctx.output.echo("Bard desktop running. Close the window to exit.")
191
+ try:
192
+ code = handle.wait()
193
+ except KeyboardInterrupt:
194
+ handle.terminate()
195
+ code = 0
196
+ raise SystemExit(code)
197
+
198
+ @bard.command("build-contribution")
199
+ @click.argument("source", type=click.Path(exists=True, file_okay=False, path_type=Path))
200
+ @click.option(
201
+ "--out",
202
+ required=True,
203
+ type=click.Path(file_okay=False, path_type=Path),
204
+ help="Directory to write the built contribution to. Replaced on every build.",
205
+ )
206
+ @click.pass_obj
207
+ def build_contribution_cmd(ctx: CliContextLike, source: Path, out: Path) -> None:
208
+ """Build a contribution for Bard to load at runtime.
209
+
210
+ SOURCE holds a bard.manifest.json naming .ts/.tsx entries that import only
211
+ @katalyst/bard-host. Writes one ES module per view or slot, its stylesheet and
212
+ the built manifest to --out; ship that directory in a Python package under the
213
+ `katalyst_bard.contributions` entry point. Requires Node.js 20+ and esbuild
214
+ installed for SOURCE (a devDependency).
215
+ """
216
+ from katalyst_plugin_bard.build import build_contribution
217
+
218
+ try:
219
+ result = build_contribution(source, out)
220
+ except RuntimeError as exc:
221
+ ctx.output.echo(f"Error: {exc}")
222
+ raise SystemExit(1) from exc
223
+
224
+ for entry in result.entries:
225
+ styles = f" + {', '.join(entry.styles)}" if entry.styles else ""
226
+ ctx.output.echo(f" {entry.id}: {entry.module}{styles}")
227
+ ctx.output.echo(f"Built {result.name} into {result.out} ({len(result.entries)} module(s))")
228
+
229
+ _ = (
230
+ serve,
231
+ desktop,
232
+ build_contribution_cmd,
233
+ ) # registered by decorators; silence unused warnings
234
+ return bard
@@ -0,0 +1,65 @@
1
+ // Bundle a Bard contribution's entries into browser ES modules.
2
+ //
3
+ // Run by `kata tax bard build-contribution`, which validates the manifest first and
4
+ // writes the built manifest after; this script only bundles. Input is one JSON
5
+ // argument: { root, out, entries: { <id>: <entry path relative to root> } }. Output,
6
+ // on stdout, is { <id>: { module, styles } } with paths relative to `out`.
7
+ //
8
+ // The modules in shared.json are not bundled: they stay bare imports, which the
9
+ // host's import map resolves to its own React and host contract. Any bundler that
10
+ // leaves exactly those external produces an equivalent module.
11
+ //
12
+ // esbuild comes from the contribution's own node_modules (a devDependency), so the
13
+ // host does not have to ship a bundler. ESBUILD_BINARY_PATH is honoured, as always.
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { createRequire } from "node:module";
17
+ import { join, relative, resolve } from "node:path";
18
+
19
+ const shared = JSON.parse(readFileSync(new URL("./shared.json", import.meta.url), "utf8"));
20
+ const { root, out, entries } = JSON.parse(process.argv[2] ?? "{}");
21
+
22
+ function loadEsbuild(from) {
23
+ try {
24
+ return createRequire(join(from, "package.json"))("esbuild");
25
+ } catch {
26
+ process.stderr.write(
27
+ `esbuild is not installed for ${from}. Add it as a devDependency ` +
28
+ "(npm install --save-dev esbuild) and run this again.\n",
29
+ );
30
+ process.exit(2);
31
+ }
32
+ }
33
+
34
+ const esbuild = loadEsbuild(resolve(root));
35
+ const absOut = resolve(out);
36
+ const result = await esbuild.build({
37
+ absWorkingDir: resolve(root),
38
+ entryPoints: Object.fromEntries(Object.entries(entries).map(([id, entry]) => [id, resolve(root, entry)])),
39
+ outdir: absOut,
40
+ entryNames: "[name]",
41
+ assetNames: "assets/[name]-[hash]",
42
+ bundle: true,
43
+ format: "esm",
44
+ platform: "browser",
45
+ target: "es2022",
46
+ jsx: "automatic",
47
+ minify: true,
48
+ legalComments: "none",
49
+ define: { "process.env.NODE_ENV": '"production"' },
50
+ loader: { ".svg": "file", ".png": "file", ".woff2": "file" },
51
+ metafile: true,
52
+ logLevel: "warning",
53
+ // Left as bare imports: the host's import map resolves them to its own instances.
54
+ external: shared.modules,
55
+ });
56
+
57
+ const built = {};
58
+ for (const [file, output] of Object.entries(result.metafile.outputs)) {
59
+ if (output.entryPoint === undefined || !file.endsWith(".js")) continue;
60
+ const id = Object.keys(entries).find((key) => resolve(root, entries[key]) === resolve(root, output.entryPoint));
61
+ if (id === undefined) continue;
62
+ const rel = (p) => `./${relative(absOut, resolve(root, p)).split("\\").join("/")}`;
63
+ built[id] = { module: rel(file), styles: output.cssBundle ? [rel(output.cssBundle)] : [] };
64
+ }
65
+ process.stdout.write(JSON.stringify(built));
@@ -0,0 +1,10 @@
1
+ {
2
+ "description": "Modules a contribution imports from the Bard host at runtime instead of bundling. The host's build serves each as a module re-exporting its own instance and maps the name to it in an import map (web/vite/shared-modules.ts), so every contribution renders with the host's React. build-contribution marks exactly these external.",
3
+ "modules": [
4
+ "@katalyst/bard-host",
5
+ "react",
6
+ "react-dom",
7
+ "react-dom/client",
8
+ "react/jsx-runtime"
9
+ ]
10
+ }