feat: build module UI into its declared browser artifact #17
+4
-2
@@ -36,8 +36,10 @@ pnpm infrastructure:config
|
|||||||
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
||||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
||||||
`pnpm boundaries`; module styling and the module UI build pipeline are not designed yet, and no
|
`pnpm boundaries`. UI source lives at `src/ui/index.tsx` and `pnpm build` compiles it to the
|
||||||
host mounts a UI entrypoint yet.
|
artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write
|
||||||
|
no bundler configuration. Styling is React inline style objects only for now, and no host mounts a
|
||||||
|
UI entrypoint yet.
|
||||||
- Module UI reaches its host only through `@ekklesia/module-ui-sdk`, and asks the host for its own
|
- Module UI reaches its host only through `@ekklesia/module-ui-sdk`, and asks the host for its own
|
||||||
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
||||||
private deployment detail and would break standalone hosting.
|
private deployment detail and would break standalone hosting.
|
||||||
|
|||||||
@@ -262,11 +262,34 @@ hints are presentation only** — server-side `requirePermission` remains the so
|
|||||||
backend trust model rather than pretending to improve on it. See
|
backend trust model rather than pretending to improve on it. See
|
||||||
[ADR 0022](docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md).
|
[ADR 0022](docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md).
|
||||||
|
|
||||||
**This is a contract, not a running feature.** No Dashboard mounts a module UI, no module ships one,
|
Core still never loads browser code: discovery validates the `entrypoints.ui` path and never
|
||||||
and the pipeline that compiles a module's `.tsx` into the compiled artifact is not designed —
|
evaluates it, and hosts do their own dynamic import.
|
||||||
`.css` and bundler plugins stay rejected for that reason. Core still never loads browser code:
|
|
||||||
discovery validates the `entrypoints.ui` path and never evaluates it, and hosts do their own dynamic
|
Phase 6B pass 2 makes that contract **buildable end to end**. A module author writes React and
|
||||||
import.
|
TypeScript at `src/ui/index.tsx` and runs its normal `pnpm build`; **esbuild** compiles it to the
|
||||||
|
browser ESM artifact the manifest declares. Authors write no bundler configuration — the new
|
||||||
|
`@ekklesia/module-ui-build` package owns all of it, and because it uses the existing `tooling` role
|
||||||
|
that modules could already depend on, **this pass required no boundary relaxation at all**. The
|
||||||
|
output path is read from the manifest rather than a build convention, so the declared and produced
|
||||||
|
artifacts cannot drift.
|
||||||
|
|
||||||
|
**React is bundled into each artifact rather than shared from the host.** That is the decision ADR
|
||||||
|
0022 implies: `mount()` is the ABI precisely so React is *not* part of the host contract, and
|
||||||
|
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
||||||
|
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
||||||
|
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
||||||
|
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar
|
||||||
|
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
||||||
|
and installing a module or starting the API never triggers one.
|
||||||
|
|
||||||
|
`templates/module` is the canonical example and now ships optional UI. Its tests build the artifact
|
||||||
|
from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered
|
||||||
|
output, and unmount it — so a UI that cannot be produced fails CI, and no generated file is ever
|
||||||
|
committed. See [ADR 0023](docs/architecture/decisions/0023-module-ui-build-pipeline.md).
|
||||||
|
|
||||||
|
**Still no host.** A module UI can be authored, built, and tested; **nothing serves one to a user
|
||||||
|
yet.** The Dashboard shell, navigation, a design system, a stylesheet loading contract, and the
|
||||||
|
standalone host do not exist.
|
||||||
|
|
||||||
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
||||||
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
||||||
|
|||||||
@@ -228,6 +228,14 @@ a way to smuggle anything else in.
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
> **Cross-reference — see [ADR 0023](0023-module-ui-build-pipeline.md).**
|
||||||
|
> The gap this section records — that module UI was not buildable end to end, because no pipeline
|
||||||
|
> turned `.tsx` into the compiled `entrypoints.ui` artifact — is now closed. ADR 0023 selects
|
||||||
|
> esbuild, bundles React rather than externalizing it (preserving this ADR's mount-function ABI),
|
||||||
|
> emits browser ESM at the manifest-declared path, and keeps stylesheet imports unsupported by
|
||||||
|
> failing the build on one. Everything else in this ADR remains accepted and unchanged.
|
||||||
|
|
||||||
|
|
||||||
A module author can now write a real screen in React, against a documented four-member host context,
|
A module author can now write a real screen in React, against a documented four-member host context,
|
||||||
and know exactly what the artifact must export. Two hosts can consume that artifact with no shared
|
and know exactly what the artifact must export. Two hosts can consume that artifact with no shared
|
||||||
code beyond one small package, and the test suite proves it by mounting one fixture artifact under
|
code beyond one small package, and the test suite proves it by mounting one fixture artifact under
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# 0023: Module UI build pipeline
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-21
|
||||||
|
- Extends: [0022](0022-module-ui-authoring-and-host-contract.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0022 defined what a module UI is — a compiled artifact default-exporting
|
||||||
|
`defineModuleUi({ id, version, mount })` — and selected React and TypeScript as the supported
|
||||||
|
authoring model. It closed by recording that the contract was not yet buildable end to end:
|
||||||
|
`browser.json` sets `"jsx": "preserve"`, which needs a bundler that no module had, `.css` and
|
||||||
|
bundler plugins were denied, and nothing turned a module's `.tsx` into the artifact its manifest
|
||||||
|
declares.
|
||||||
|
|
||||||
|
So a module author could read the contract but not satisfy it. This ADR closes exactly that gap and
|
||||||
|
nothing else.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### esbuild is the module UI bundler
|
||||||
|
|
||||||
|
esbuild produces the compiled artifact. It was already the right answer before it was chosen: the
|
||||||
|
repository resolves esbuild 0.28.1 today through Vite/Vitest, and `pnpm-workspace.yaml` already
|
||||||
|
carries `allowBuilds: esbuild: true`, so its native binary is an accepted, reviewed part of this
|
||||||
|
workspace. It is pinned to that same 0.28.1 rather than a floating range, so no second copy enters
|
||||||
|
the store.
|
||||||
|
|
||||||
|
It is also the smallest tool that satisfies every requirement: TSX transformation, ESM output,
|
||||||
|
browser platform, bundling, source maps, and a define-time constant, with no plugin ecosystem and no
|
||||||
|
application framework. Vite library mode was rejected because it would pull a Rollup build plus
|
||||||
|
`@vitejs/plugin-react` — a package the module boundary deliberately denies — for output this does
|
||||||
|
not need. tsup was rejected as an esbuild wrapper that adds a dependency and no capability. Plain
|
||||||
|
`tsc` cannot do it at all: it transpiles TSX but cannot bundle, so its output would carry bare
|
||||||
|
`react/jsx-runtime` and `react-dom/client` imports that no browser can resolve without an import map
|
||||||
|
the platform does not define.
|
||||||
|
|
||||||
|
### React and ReactDOM are bundled, never externalized
|
||||||
|
|
||||||
|
Every dependency, React included, is bundled into the artifact. Nothing is external, so a host
|
||||||
|
provides no modules, no globals, and no import map.
|
||||||
|
|
||||||
|
This follows directly from ADR 0022. That ADR made `mount()` the ABI *specifically* so React would
|
||||||
|
not be part of the host contract. Externalizing React would reverse that decision through the build
|
||||||
|
system: the host would have to supply a React runtime, module and host would be locked to compatible
|
||||||
|
majors, and a standalone host — or any host not written in React — could not load a module at all.
|
||||||
|
Host independence is the property this platform is trying to keep; bundle size is not.
|
||||||
|
|
||||||
|
The accepted cost is duplication: a page hosting several module UIs may load several React copies,
|
||||||
|
and the template's artifact is roughly 570 KB unminified. That is a real cost and it is deliberate.
|
||||||
|
Sharing one runtime later through peer dependencies, bundler externals, or an import map is a
|
||||||
|
build-pipeline optimization that **does not change the public contract**, because the contract is a
|
||||||
|
mount function rather than a component. It can be revisited when a real host exists to measure.
|
||||||
|
|
||||||
|
### Output is unminified browser ESM at the path the manifest declares
|
||||||
|
|
||||||
|
`format: "esm"`, `platform: "browser"`, `target: "es2022"` (matching `browser.json`), with source
|
||||||
|
maps, and `process.env.NODE_ENV` replaced at build time — React reads that at module scope, and
|
||||||
|
without substitution the artifact would reference a Node global that does not exist in a browser.
|
||||||
|
|
||||||
|
Output is **not** minified. Nothing else in this repository minifies, and
|
||||||
|
[the security model](../../development/security.md) asks an operator to review the artifact they
|
||||||
|
install; a minified bundle is materially harder to review.
|
||||||
|
|
||||||
|
The artifact path is not a build convention — it is read from the module's own manifest
|
||||||
|
`entrypoints.ui`, and the source is `src/ui/index.tsx` by convention. Deriving the output from the
|
||||||
|
manifest means the declared artifact and the produced artifact cannot drift, and the build validates
|
||||||
|
that path with `packageRelativePathSchema` from `@ekklesia/contracts`, the same grammar module
|
||||||
|
discovery enforces, so a build can never write somewhere discovery would later reject.
|
||||||
|
|
||||||
|
### `@ekklesia/module-ui-build` owns the configuration; modules own none
|
||||||
|
|
||||||
|
The bundler configuration lives in one shared package with the existing `tooling` role — a role
|
||||||
|
modules were already permitted to depend on, so **this pass required no boundary relaxation at
|
||||||
|
all**. A module author writes no bundler configuration; they place UI at `src/ui/index.tsx`, declare
|
||||||
|
`entrypoints.ui`, and run their normal build.
|
||||||
|
|
||||||
|
The package ships a committed launcher at `bin/ekklesia-build-module-ui.mjs` rather than pointing its
|
||||||
|
`bin` straight at compiled output. pnpm creates `.bin` links during `pnpm install`, which runs before
|
||||||
|
Turborepo builds anything, so a `bin` targeting `dist/cli.js` fails to link on a fresh clone and
|
||||||
|
every module's UI build breaks in CI. The committed launcher always exists, so the link always
|
||||||
|
succeeds, and it defers to the compiled CLI at run time.
|
||||||
|
|
||||||
|
A module with no `entrypoints.ui` is not an error: the CLI reports that there is nothing to build and
|
||||||
|
exits successfully, so one build script serves backend-only and UI-bearing modules alike.
|
||||||
|
|
||||||
|
### Styling is inline style objects only, for now
|
||||||
|
|
||||||
|
Module UI styles with React inline `style` objects. Stylesheet imports remain unsupported, and the
|
||||||
|
build **fails** on one rather than quietly emitting a sidecar.
|
||||||
|
|
||||||
|
esbuild would happily bundle `import "./styles.css"` into an `index.css` next to the artifact. No
|
||||||
|
manifest field declares that file, no host loads it, and nothing in the runtime contract mentions it
|
||||||
|
— so it would appear to work in a local test and silently do nothing in a real host. Emitting an
|
||||||
|
undeclared asset is worse than refusing one. `scripts/check-boundaries.ts` already rejects a `.css`
|
||||||
|
file authored inside a module; the build additionally rejects a stylesheet reached through a
|
||||||
|
dependency, with an explanatory error.
|
||||||
|
|
||||||
|
This is deliberately the smallest viable styling story. A real stylesheet contract needs a manifest
|
||||||
|
field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing
|
||||||
|
them here would expand the manifest and runtime contract before a host can validate them.
|
||||||
|
|
||||||
|
### Browser TypeScript stays scoped to UI source
|
||||||
|
|
||||||
|
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
|
||||||
|
`"jsx": "react-jsx"`, matching esbuild's `jsx: "automatic"` so typing and emission agree.
|
||||||
|
|
||||||
|
A module keeps separate TypeScript projects: its Node project continues to include `src/**/*.ts`,
|
||||||
|
which does not match `.tsx`, and a UI project covers `src/ui/**` with `"types": []`. Backend module
|
||||||
|
code therefore never inherits DOM globals, and UI source is never compiled with Node settings.
|
||||||
|
|
||||||
|
The reverse direction is weaker than it looks, and is recorded here rather than overstated.
|
||||||
|
`"types": []` does keep `@types/node` out of the UI program — a file that references `process` in
|
||||||
|
isolation fails with TS2591 — but TypeScript still pulls global declarations in transitively through
|
||||||
|
a dependency's type graph. Measured on this branch: a UI file that imports
|
||||||
|
`@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts`, and through its Zod and Drizzle types the
|
||||||
|
Node globals become visible again, so `process.env` typechecks. The separation is therefore a strong
|
||||||
|
convention, not an airtight guard, and nothing at build time closes it either: esbuild substitutes
|
||||||
|
only `process.env.NODE_ENV`, so any other Node global that slips through fails in the browser rather
|
||||||
|
than in CI. Closing this properly means giving the UI SDK a type surface that does not reach the
|
||||||
|
backend contracts barrel, which is an ADR 0022 contract question and is deferred.
|
||||||
|
|
||||||
|
### The build is an authoring step; discovery still only validates
|
||||||
|
|
||||||
|
Nothing here runs during discovery, startup, or install. The lifecycle is unchanged: an author
|
||||||
|
builds, the compiled artifact is packaged and installed, and Core discovery validates that artifact
|
||||||
|
without executing it (ADR 0021). Core does not build, does not import browser code, and gains no new
|
||||||
|
responsibility in this ADR.
|
||||||
|
|
||||||
|
### The pipeline is proven by the module template, in the repository's own verification
|
||||||
|
|
||||||
|
`templates/module` is the canonical example and now ships optional UI: `src/ui/index.tsx` and one
|
||||||
|
small React component. Its test suite builds the artifact from source, imports it, validates it with
|
||||||
|
`parseModuleUiExport`, mounts it into a real DOM, asserts the rendered output, and unmounts it.
|
||||||
|
|
||||||
|
The artifact is rebuilt in the test rather than read from a checked-in file, so the proof can never
|
||||||
|
pass against stale output — and `dist/` is gitignored, so nothing generated is committed. Because
|
||||||
|
`pnpm verify` runs both `pnpm test` and `pnpm build`, a module UI that cannot be produced fails CI
|
||||||
|
through two independent paths.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Vite library mode.** Rejected: heavier than the job requires, and its React support routes
|
||||||
|
through `@vitejs/plugin-react`, which the module boundary denies precisely so module authors do not
|
||||||
|
acquire a plugin ecosystem.
|
||||||
|
- **tsup.** Rejected: a wrapper around the esbuild this repository already resolves, adding a
|
||||||
|
dependency and no capability.
|
||||||
|
- **`tsc` alone.** Rejected: it cannot bundle. Its output would carry unresolvable bare imports and
|
||||||
|
would require the platform to define an import map — which would make React part of the host ABI
|
||||||
|
by another route.
|
||||||
|
- **Externalizing React.** Rejected as the default, in detail above: it undoes ADR 0022's central
|
||||||
|
decision and makes standalone hosting impractical. Revisit as an optimization when a real host can
|
||||||
|
measure the cost.
|
||||||
|
- **Minified output.** Rejected: nothing else here minifies, and the trusted-module model asks
|
||||||
|
operators to review what they install.
|
||||||
|
- **A build convention instead of reading the manifest.** Rejected: two independent statements of
|
||||||
|
where the artifact lives can disagree, and the failure would surface as a discovery error long
|
||||||
|
after the build "succeeded".
|
||||||
|
- **Emitting a CSS sidecar.** Rejected: an asset nothing declares and no host loads is a silent
|
||||||
|
failure, and adding the manifest and runtime contract to support one belongs with a host that can
|
||||||
|
validate it.
|
||||||
|
- **CSS Modules or a CSS-in-JS library.** Rejected for this pass: both are larger commitments than
|
||||||
|
the smallest viable styling story, and one would add a runtime dependency to every module.
|
||||||
|
- **A separate UI fixture instead of the template.** Rejected: the template is what an author copies,
|
||||||
|
so proving the pipeline anywhere else would leave the canonical example unproven. Optionality is
|
||||||
|
preserved instead — a backend-only module deletes the UI directory, its two UI TypeScript projects,
|
||||||
|
and the `ui` entrypoint, and everything else still works.
|
||||||
|
- **A `bin` pointing directly at compiled output.** Rejected: it cannot link on a fresh clone,
|
||||||
|
because installation precedes the build.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A module author can now write React and TypeScript, run their normal build, and get exactly the
|
||||||
|
artifact their manifest declares — with no bundler configuration of their own. The end-to-end path
|
||||||
|
from ADR 0021's static field through ADR 0022's contract to a mounted, unmountable UI is closed and
|
||||||
|
tested.
|
||||||
|
|
||||||
|
The costs are explicit: bundled React means duplicate runtimes and large artifacts until a sharing
|
||||||
|
strategy is measured against a real host; inline styles are the only styling available; and a module
|
||||||
|
that wants a stylesheet, a router, or a component library has none of them yet. Module UI also still
|
||||||
|
has no production host — the Dashboard does not exist, so a built artifact is proven by tests rather
|
||||||
|
than served to anyone.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Not implemented or promised here: the Dashboard application shell, navigation, or grouping; a design
|
||||||
|
system, component library, or theme/design-token contract; a stylesheet or asset loading contract and
|
||||||
|
the manifest field it would need; React runtime sharing between host and modules; minified or
|
||||||
|
production-optimized output; a standalone application host; module installation, marketplace, or hot
|
||||||
|
loading; and every real feature module. `entrypoints.jobs` still accepts source TypeScript where
|
||||||
|
other executable entrypoints require compiled output; this pass reused the *path* grammar from
|
||||||
|
`@ekklesia/contracts` but did not centralize the *extension* rule, so that inconsistency is
|
||||||
|
unchanged and still open.
|
||||||
@@ -31,3 +31,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
|
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
|
||||||
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
|
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
|
||||||
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
|
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
|
||||||
|
- [0023: Module UI build pipeline](0023-module-ui-build-pipeline.md) — Accepted
|
||||||
|
|||||||
@@ -195,8 +195,17 @@ authority. Module UI is trusted code in the host's browser realm — there is no
|
|||||||
backend trust model rather than overstating it.
|
backend trust model rather than overstating it.
|
||||||
|
|
||||||
Core is untouched by this: discovery still validates the `entrypoints.ui` path and never evaluates
|
Core is untouched by this: discovery still validates the `entrypoints.ui` path and never evaluates
|
||||||
it, and hosts perform their own dynamic import. The Dashboard shell, a UI runtime, the module UI
|
it, and hosts perform their own dynamic import.
|
||||||
build and styling pipeline, the standalone host, and every feature module remain future work.
|
|
||||||
|
**Phase 6B pass 2 makes that contract buildable.**
|
||||||
|
[ADR 0023](decisions/0023-module-ui-build-pipeline.md) selects esbuild, compiles a module's
|
||||||
|
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
|
||||||
|
option in one `tooling` package so module authors write no build configuration. React is bundled
|
||||||
|
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
|
||||||
|
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
|
||||||
|
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
|
||||||
|
only validates the artifact a module shipped. The Dashboard shell, a UI runtime, a design system, a
|
||||||
|
stylesheet loading contract, the standalone host, and every feature module remain future work.
|
||||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||||
are all unchanged.
|
are all unchanged.
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
|
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
|
||||||
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
||||||
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
|
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
|
||||||
packages. An unsupported framework aliased behind an allowed name is resolved and rejected by its
|
packages. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
||||||
|
a role modules could already depend on — owns the entire module UI build
|
||||||
|
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
|
||||||
real target.
|
real target.
|
||||||
- Module UI source is subject to the identical layer rules as module backend source: it may not
|
- Module UI source is subject to the identical layer rules as module backend source: it may not
|
||||||
import Core, an app, a surface, a builder, or another module. Core still owns the containing
|
import Core, an app, a surface, a builder, or another module. Core still owns the containing
|
||||||
|
|||||||
@@ -271,25 +271,120 @@ exposes. This matches the backend model: installing a module already means runni
|
|||||||
API process. Write module UI as trusted first-party code, because that is what it is. See
|
API process. Write module UI as trusted first-party code, because that is what it is. See
|
||||||
[Security model and deployment trust boundaries](../development/security.md).
|
[Security model and deployment trust boundaries](../development/security.md).
|
||||||
|
|
||||||
|
### Building your UI
|
||||||
|
|
||||||
|
Put your UI source at `src/ui/index.tsx`, declare the artifact in your manifest, and build:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/ui/index.tsx # the compiled entrypoint's source
|
||||||
|
src/ui/ExampleApp.tsx # your components
|
||||||
|
↓ pnpm build
|
||||||
|
dist/ui/index.js # the artifact your manifest declares
|
||||||
|
```
|
||||||
|
|
||||||
|
You write no bundler configuration. `@ekklesia/module-ui-build` owns all of it
|
||||||
|
([ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)), and your package's build
|
||||||
|
script runs both halves of the module:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ekklesia/module-ui-build": "workspace:*",
|
||||||
|
"@types/react": "19.2.18",
|
||||||
|
"@types/react-dom": "19.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The output path is **read from your manifest**, not from a build convention, so the artifact you
|
||||||
|
declare and the artifact you produce cannot drift. Declare it exactly as you would any other
|
||||||
|
entrypoint:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "entrypoints": { "ui": "./dist/ui/index.js" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
A module with no `entrypoints.ui` is not an error — the build reports that there is nothing to build
|
||||||
|
and exits successfully, so the same script works for a backend-only module.
|
||||||
|
|
||||||
|
The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not
|
||||||
|
compile, an import cannot be resolved, the declared path is unsafe or is not a `.js`/`.mjs` artifact,
|
||||||
|
or `src/ui/index.tsx` is missing.
|
||||||
|
|
||||||
|
#### TypeScript projects
|
||||||
|
|
||||||
|
Keep UI and backend TypeScript separate. Your Node project already includes `src/**/*.ts`, which does
|
||||||
|
not match `.tsx`, and a UI project covers the browser half:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||||
|
"compilerOptions": { "noEmit": true, "types": [] },
|
||||||
|
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"types": []` is deliberate: UI source should not reach for Node globals. Be aware this is a
|
||||||
|
convention rather than an airtight guard — TypeScript still pulls global declarations in
|
||||||
|
transitively through dependency type graphs, so `process.env` currently typechecks in a UI file that
|
||||||
|
imports `@ekklesia/module-ui-sdk`. Nothing catches it at build time either: only
|
||||||
|
`process.env.NODE_ENV` is substituted, so any other Node global reaches the browser and fails there.
|
||||||
|
Treat "no Node globals in UI source" as a rule you keep, not one the compiler keeps for you.
|
||||||
|
|
||||||
|
`noEmit` is also deliberate — TypeScript only checks types here; esbuild produces the artifact.
|
||||||
|
|
||||||
|
#### What is in your artifact
|
||||||
|
|
||||||
|
**React is bundled into it.** Your artifact is self-contained: it resolves nothing at run time and
|
||||||
|
needs no React from its host, which is what lets the same file run under Ekklesia and under a future
|
||||||
|
standalone host that may not use React at all. The cost is that a page hosting several module UIs
|
||||||
|
loads several React copies, and artifacts are large (the template's is roughly 570 KB, unminified so
|
||||||
|
an operator can review it). That is accepted for now and can be optimized later **without changing
|
||||||
|
the contract**, because the contract is a mount function rather than a component.
|
||||||
|
|
||||||
|
### Styling: inline style objects only, for now
|
||||||
|
|
||||||
|
Style with React's `style` prop:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<section style={{ display: "grid", gap: "0.5rem" }}>…</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
|
||||||
|
an oversight: esbuild would emit a `dist/ui/index.css` beside your artifact, but no manifest field
|
||||||
|
declares it and no host loads it — so it would work in your tests and silently do nothing in a real
|
||||||
|
host. A stylesheet contract needs a manifest field, a host loading mechanism, and a style-isolation
|
||||||
|
decision, none of which exist yet. Inline styles are the whole supported story for this phase.
|
||||||
|
|
||||||
### What you may and may not use today
|
### What you may and may not use today
|
||||||
|
|
||||||
`pnpm boundaries` still enforces `modules-do-not-own-ui`, narrowed precisely (ADR 0022):
|
`pnpm boundaries` enforces `modules-do-not-own-ui`, narrowed precisely (ADR 0022) and **unchanged by
|
||||||
|
the build pipeline** — adding the pipeline required no boundary relaxation at all:
|
||||||
|
|
||||||
| Allowed in a module | Still rejected |
|
| Allowed in a module | Still rejected |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
||||||
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
|
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
|
||||||
| `@ekklesia/module-ui-sdk` | `@ekklesia/ui` and other shared UI-role packages |
|
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build` | `@ekklesia/ui` and other shared UI-role packages |
|
||||||
|
|
||||||
|
You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because
|
||||||
|
`@ekklesia/module-ui-build` owns the bundler entirely.
|
||||||
|
|
||||||
Layer rules are unchanged: module UI source may not import Core, an app, a surface, a builder, or
|
Layer rules are unchanged: module UI source may not import Core, an app, a surface, a builder, or
|
||||||
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
|
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
|
||||||
is resolved and rejected by its real target.
|
is resolved and rejected by its real target.
|
||||||
|
|
||||||
> **Not yet buildable end to end.** The pipeline that compiles a module's `.tsx` into the compiled
|
> **Still no host.** You can now author, build, and test a module UI end to end. **Nothing serves it
|
||||||
> `entrypoints.ui` artifact is not designed: `browser.json` uses `"jsx": "preserve"`, which needs a
|
> to a user yet** — the Dashboard shell does not exist, and neither does the standalone host. Your
|
||||||
> bundler, and `@vitejs/plugin-react` is still denied. Module styling is unresolved for the same
|
> artifact is proven by your own tests, not mounted in production.
|
||||||
> reason, which is why `.css` stays rejected. No host mounts a module UI yet either. The contract
|
|
||||||
> above is stable and tested; shipping a module UI is not yet possible end to end.
|
|
||||||
|
|
||||||
### What this does and does not do today
|
### What this does and does not do today
|
||||||
|
|
||||||
@@ -299,6 +394,10 @@ entrypoint** — a UI artifact that throws on evaluation still discovers success
|
|||||||
never evaluated. Hosts perform their own dynamic import and then validate the result with
|
never evaluated. Hosts perform their own dynamic import and then validate the result with
|
||||||
`parseModuleUiExport`; Core never loads browser code.
|
`parseModuleUiExport`; Core never loads browser code.
|
||||||
|
|
||||||
|
Core also never *builds* anything. Building is an authoring and packaging step you run; Core
|
||||||
|
discovers the compiled artifact you shipped and validates it. Installing a module does not trigger a
|
||||||
|
build, and neither does starting the API.
|
||||||
|
|
||||||
### What a module UI will never own
|
### What a module UI will never own
|
||||||
|
|
||||||
A module UI consumes platform policy and never redefines it.
|
A module UI consumes platform policy and never redefines it.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Committed launcher, deliberately not generated.
|
||||||
|
//
|
||||||
|
// pnpm creates `.bin` links during `pnpm install`, which runs before Turborepo builds anything. A
|
||||||
|
// `bin` pointing straight at `dist/cli.js` therefore fails to link on a fresh clone — the compiled
|
||||||
|
// file does not exist yet — and every module's UI build would break in CI. This file always exists,
|
||||||
|
// so the link always succeeds; it defers to the compiled CLI at run time, by which point
|
||||||
|
// `dependsOn: ["^build"]` has produced it.
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const compiledCli = new URL("../dist/cli.js", import.meta.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await import(compiledCli.href);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ERR_MODULE_NOT_FOUND") {
|
||||||
|
process.stderr.write(
|
||||||
|
`@ekklesia/module-ui-build is not built yet (${fileURLToPath(compiledCli)}).\n` +
|
||||||
|
"Run `pnpm build` from the repository root first.\n",
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-ui-build",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"bin",
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"ekklesia-build-module-ui": "./bin/ekklesia-build-module-ui.mjs"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/contracts": "workspace:*",
|
||||||
|
"esbuild": "0.28.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "tooling"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { packageRelativePathSchema } from "@ekklesia/contracts";
|
||||||
|
import { build, type Plugin } from "esbuild";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one supported way to turn a module's React/TSX UI source into the compiled browser artifact
|
||||||
|
* its manifest declares as `entrypoints.ui` (ADR 0021, ADR 0022, ADR 0023).
|
||||||
|
*
|
||||||
|
* Module authors do not write bundler configuration. They put their UI at
|
||||||
|
* {@link MODULE_UI_SOURCE_ENTRYPOINT}, declare `entrypoints.ui` in their manifest, and run the
|
||||||
|
* build; every option below is owned here so one decision applies to every module.
|
||||||
|
*/
|
||||||
|
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
|
||||||
|
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||||
|
|
||||||
|
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
|
||||||
|
export const MODULE_UI_BUILD_TARGET = "es2022";
|
||||||
|
|
||||||
|
export type ModuleUiBuildErrorCode =
|
||||||
|
| "MODULE_UI_MANIFEST_UNREADABLE"
|
||||||
|
| "MODULE_UI_ENTRYPOINT_NOT_DECLARED"
|
||||||
|
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
||||||
|
| "MODULE_UI_SOURCE_MISSING"
|
||||||
|
| "MODULE_UI_BUILD_FAILED";
|
||||||
|
|
||||||
|
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
||||||
|
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
||||||
|
MODULE_UI_ENTRYPOINT_NOT_DECLARED: `${MODULE_MANIFEST_FILENAME} declares no entrypoints.ui, so there is no module UI to build.`,
|
||||||
|
MODULE_UI_ENTRYPOINT_UNSAFE: `entrypoints.ui must be a package-relative path to a compiled .js or .mjs artifact inside the module.`,
|
||||||
|
MODULE_UI_SOURCE_MISSING: `Module UI source was not found at ${MODULE_UI_SOURCE_ENTRYPOINT}.`,
|
||||||
|
MODULE_UI_BUILD_FAILED: "The module UI bundle could not be produced.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by
|
||||||
|
* the CLI, where a developer wants them; the thrown error itself stays sanitized, matching every
|
||||||
|
* other Ekklesia error boundary. */
|
||||||
|
export class ModuleUiBuildError extends Error {
|
||||||
|
readonly code: ModuleUiBuildErrorCode;
|
||||||
|
|
||||||
|
constructor(code: ModuleUiBuildErrorCode) {
|
||||||
|
super(errorMessages[code]);
|
||||||
|
this.name = "ModuleUiBuildError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects a stylesheet import instead of quietly emitting one.
|
||||||
|
*
|
||||||
|
* esbuild would happily bundle `import "./styles.css"` into a sidecar `index.css` next to the
|
||||||
|
* artifact. Nothing declares that file, no host loads it, and the manifest has no field for it — so
|
||||||
|
* it would work in a local test and silently do nothing in a real host. Module styling has no
|
||||||
|
* public loading contract yet (ADR 0023), so the build fails loudly rather than producing an
|
||||||
|
* undeclared asset. `scripts/check-boundaries.ts` rejects a `.css` file authored *inside* a module;
|
||||||
|
* this additionally covers a stylesheet reached through a dependency.
|
||||||
|
*/
|
||||||
|
function rejectStylesheetImports(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "ekklesia-reject-stylesheet-imports",
|
||||||
|
setup(pluginBuild) {
|
||||||
|
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
|
||||||
|
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). This pattern is
|
||||||
|
// pure ASCII, so the flag would change nothing anyway.
|
||||||
|
pluginBuild.onResolve({ filter: /\.(css|s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
"Module UI cannot import a stylesheet yet: there is no declared way for a host to " +
|
||||||
|
"load one. Use React inline style objects for now (ADR 0023).",
|
||||||
|
location: { file: args.importer },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildModuleUiOptions {
|
||||||
|
/** The module package directory containing `ekklesia.module.json`. */
|
||||||
|
readonly moduleDirectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleUiBuildResult {
|
||||||
|
/** Absolute path of the artifact actually written, which is exactly what the manifest declares. */
|
||||||
|
readonly outputFile: string;
|
||||||
|
/** Absolute path of the source entrypoint that produced it. */
|
||||||
|
readonly sourceFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The declared artifact path, validated the same way module discovery validates it, so the build
|
||||||
|
* can never write somewhere discovery would later reject. */
|
||||||
|
async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string> {
|
||||||
|
let manifest: unknown;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(
|
||||||
|
await readFile(path.join(moduleDirectory, MODULE_MANIFEST_FILENAME), "utf8"),
|
||||||
|
) as unknown;
|
||||||
|
} catch {
|
||||||
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined;
|
||||||
|
const declared = isRecord(entrypoints) ? entrypoints.ui : undefined;
|
||||||
|
if (declared === undefined) {
|
||||||
|
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_NOT_DECLARED");
|
||||||
|
}
|
||||||
|
// The path grammar is Core's, not this tool's: reusing the contracts schema means the build can
|
||||||
|
// never write to a location module discovery would later reject, even if that grammar changes.
|
||||||
|
if (
|
||||||
|
!packageRelativePathSchema.safeParse(declared).success ||
|
||||||
|
typeof declared !== "string" ||
|
||||||
|
![".js", ".mjs"].includes(path.extname(declared).toLowerCase())
|
||||||
|
) {
|
||||||
|
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE");
|
||||||
|
}
|
||||||
|
return path.resolve(moduleDirectory, declared);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundles a module's UI into the artifact its manifest declares.
|
||||||
|
*
|
||||||
|
* Never runs during discovery, startup, or install: this is an authoring-time and packaging-time
|
||||||
|
* step only. Core validates the artifact a module ships; it never produces one (ADR 0021).
|
||||||
|
*/
|
||||||
|
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
|
||||||
|
const moduleDirectory = path.resolve(options.moduleDirectory);
|
||||||
|
const outputFile = await resolveDeclaredArtifact(moduleDirectory);
|
||||||
|
const sourceFile = path.join(moduleDirectory, MODULE_UI_SOURCE_ENTRYPOINT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await readFile(sourceFile);
|
||||||
|
} catch {
|
||||||
|
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await build({
|
||||||
|
absWorkingDir: moduleDirectory,
|
||||||
|
// Every dependency, React included, is bundled. The artifact must run in any host, including
|
||||||
|
// one that uses no React at all, so nothing is externalized (ADR 0023).
|
||||||
|
bundle: true,
|
||||||
|
define: {
|
||||||
|
// React reads this at module scope. Without it the bundle would reference `process`, a
|
||||||
|
// Node global that does not exist in a browser, and crash on load.
|
||||||
|
"process.env.NODE_ENV": '"production"',
|
||||||
|
},
|
||||||
|
entryPoints: [sourceFile],
|
||||||
|
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
|
||||||
|
// requires a real default export.
|
||||||
|
format: "esm",
|
||||||
|
jsx: "automatic",
|
||||||
|
// Unminified deliberately: an operator is expected to review the artifact they install
|
||||||
|
// (see docs/development/security.md), and nothing else in this repository minifies.
|
||||||
|
minify: false,
|
||||||
|
outfile: outputFile,
|
||||||
|
platform: "browser",
|
||||||
|
plugins: [rejectStylesheetImports()],
|
||||||
|
sourcemap: true,
|
||||||
|
target: MODULE_UI_BUILD_TARGET,
|
||||||
|
// Fail rather than silently shipping a partial bundle.
|
||||||
|
logLevel: "silent",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
|
||||||
|
// programmatic caller never receives raw paths or internals.
|
||||||
|
if (isRecord(error) && Array.isArray(error.errors)) {
|
||||||
|
throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED"), {
|
||||||
|
diagnostics: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { outputFile, sourceFile };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { formatMessages } from "esbuild";
|
||||||
|
import { buildModuleUi, ModuleUiBuildError } from "./build-module-ui.js";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ekklesia-build-module-ui` — the canonical module UI build command, run from a module directory.
|
||||||
|
*
|
||||||
|
* A module with no `entrypoints.ui` is not an error: backend-only modules stay first-class and this
|
||||||
|
* command reports that there was nothing to build and exits successfully, so one `build` script
|
||||||
|
* works for every module.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const moduleDirectory = process.argv[2] ?? process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await buildModuleUi({ moduleDirectory });
|
||||||
|
process.stdout.write(`Built module UI: ${path.relative(moduleDirectory, result.outputFile)}\n`);
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ModuleUiBuildError) {
|
||||||
|
if (error.code === "MODULE_UI_ENTRYPOINT_NOT_DECLARED") {
|
||||||
|
process.stdout.write("No entrypoints.ui declared; skipping the module UI build.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
process.stderr.write(`${error.message}\n`);
|
||||||
|
// esbuild's own diagnostics carry the file, line, and reason a developer needs. They are
|
||||||
|
// printed here, never folded into the sanitized error message itself.
|
||||||
|
const diagnostics = isRecord(error) ? error.diagnostics : undefined;
|
||||||
|
if (Array.isArray(diagnostics) && diagnostics.length > 0) {
|
||||||
|
const formatted = await formatMessages(diagnostics, { kind: "error", color: true });
|
||||||
|
process.stderr.write(formatted.join(""));
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
process.stderr.write("The module UI build failed unexpectedly.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = await main();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./build-module-ui.js";
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { buildModuleUi, ModuleUiBuildError } from "../src/index.js";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ModuleFixtureOptions {
|
||||||
|
/** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */
|
||||||
|
readonly ui?: string;
|
||||||
|
readonly manifest?: string;
|
||||||
|
readonly files?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(options: ModuleFixtureOptions = {}): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-build-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
|
||||||
|
const manifest =
|
||||||
|
options.manifest ??
|
||||||
|
JSON.stringify({
|
||||||
|
manifestVersion: 4,
|
||||||
|
id: "example",
|
||||||
|
name: "Example",
|
||||||
|
version: "0.1.0",
|
||||||
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(options.ui === undefined ? {} : { ui: options.ui }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeFile(path.join(directory, "ekklesia.module.json"), manifest);
|
||||||
|
|
||||||
|
for (const [relativePath, contents] of Object.entries(options.files ?? {})) {
|
||||||
|
const target = path.join(directory, relativePath);
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, contents);
|
||||||
|
}
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A UI entrypoint with no React, so the build tool's own tests exercise the pipeline mechanics
|
||||||
|
* without depending on a UI framework. The real React path is proven end to end by the module
|
||||||
|
* template's own test suite. */
|
||||||
|
const plainUiSource = `
|
||||||
|
const definition = {
|
||||||
|
id: "example",
|
||||||
|
version: "0.1.0",
|
||||||
|
mount(container) {
|
||||||
|
container.textContent = "example";
|
||||||
|
return () => {
|
||||||
|
container.textContent = "";
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default definition;
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("module UI build", () => {
|
||||||
|
it("bundles the declared entrypoint into the exact path the manifest names", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: { "src/ui/index.tsx": plainUiSource },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
|
||||||
|
expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js"));
|
||||||
|
const artifact = await readFile(result.outputFile, "utf8");
|
||||||
|
expect(artifact).toContain("export {");
|
||||||
|
expect(artifact).toContain("as default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an alternative declared artifact path", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./build/browser/ui.mjs",
|
||||||
|
files: { "src/ui/index.tsx": plainUiSource },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
|
||||||
|
expect(result.outputFile).toBe(path.join(directory, "build", "browser", "ui.mjs"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a source map alongside the artifact", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: { "src/ui/index.tsx": plainUiSource },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
|
||||||
|
await expect(readFile(`${result.outputFile}.map`, "utf8")).resolves.toContain("sources");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a backend-only module as having no UI to build rather than failing silently", async () => {
|
||||||
|
const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } });
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the declared UI has no source entrypoint", async () => {
|
||||||
|
const directory = await createModule({ ui: "./dist/ui/index.js" });
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_SOURCE_MISSING",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the manifest cannot be read", async () => {
|
||||||
|
const directory = await createModule({ manifest: "{ not json" });
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_MANIFEST_UNREADABLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["escapes the package", "../outside/ui.js"],
|
||||||
|
["is absolute", "/tmp/ui.js"],
|
||||||
|
["traverses upward", "./dist/../../ui.js"],
|
||||||
|
["is source TypeScript", "./src/ui/index.tsx"],
|
||||||
|
["carries a query", "./dist/ui/index.js?raw"],
|
||||||
|
["uses backslashes", ".\\dist\\ui\\index.js"],
|
||||||
|
])("refuses to write an artifact whose declared path %s", async (_label, ui) => {
|
||||||
|
const directory = await createModule({ ui, files: { "src/ui/index.tsx": plainUiSource } });
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a build failure with esbuild's diagnostics attached", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: { "src/ui/index.tsx": "export default { id: 'example', " },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
expect.unreachable("expected the build to fail");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModuleUiBuildError);
|
||||||
|
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
|
||||||
|
// The sanitized message carries no path; the diagnostics a developer needs are attached.
|
||||||
|
expect((error as ModuleUiBuildError).message).not.toContain(directory);
|
||||||
|
expect((error as { diagnostics?: unknown[] }).diagnostics?.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails on an unresolvable import instead of emitting a partial bundle", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: { "src/ui/index.tsx": 'import "totally-not-installed";\nexport default {};' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_BUILD_FAILED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"styles.css",
|
||||||
|
"theme.scss",
|
||||||
|
"layout.less",
|
||||||
|
])("rejects a %s import rather than emitting an undeclared sidecar asset", async (stylesheet) => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": `import "./${stylesheet}";\nexport default {};`,
|
||||||
|
[`src/ui/${stylesheet}`]: ".example { color: red; }",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
expect.unreachable("expected the stylesheet import to be rejected");
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
|
||||||
|
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
|
||||||
|
expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never leaves a stylesheet sidecar next to the artifact", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": 'import "./styles.css";\nexport default {};',
|
||||||
|
"src/ui/styles.css": ".example { color: red; }",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toBeInstanceOf(
|
||||||
|
ModuleUiBuildError,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx":
|
||||||
|
'const mode = process.env.NODE_ENV;\nexport default { id: "example", version: "0.1.0", mount: () => { void mode; } };',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
const artifact = await readFile(result.outputFile, "utf8");
|
||||||
|
|
||||||
|
expect(artifact).toContain('"production"');
|
||||||
|
expect(artifact).not.toContain("process.env.NODE_ENV");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "tests"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../typescript-config/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./browser.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
"browser-react.json",
|
||||||
"browser.json",
|
"browser.json",
|
||||||
"node.json",
|
"node.json",
|
||||||
"react-native.json"
|
"react-native.json"
|
||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
"./base.json": "./base.json",
|
"./base.json": "./base.json",
|
||||||
|
"./browser-react.json": "./browser-react.json",
|
||||||
"./browser.json": "./browser.json",
|
"./browser.json": "./browser.json",
|
||||||
"./node.json": "./node.json",
|
"./node.json": "./node.json",
|
||||||
"./react-native.json": "./react-native.json"
|
"./react-native.json": "./react-native.json"
|
||||||
|
|||||||
Generated
+136
-15
@@ -29,7 +29,7 @@ importers:
|
|||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
yaml:
|
yaml:
|
||||||
specifier: 2.9.0
|
specifier: 2.9.0
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
@@ -63,7 +63,7 @@ importers:
|
|||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: 1.6.23
|
specifier: 1.6.23
|
||||||
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
||||||
pg:
|
pg:
|
||||||
specifier: 8.22.0
|
specifier: 8.22.0
|
||||||
version: 8.22.0
|
version: 8.22.0
|
||||||
@@ -72,7 +72,7 @@ importers:
|
|||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
core:
|
core:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -81,7 +81,7 @@ importers:
|
|||||||
version: link:../packages/contracts
|
version: link:../packages/contracts
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: 1.6.23
|
specifier: 1.6.23
|
||||||
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: 0.45.2
|
specifier: 0.45.2
|
||||||
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||||
@@ -100,7 +100,7 @@ importers:
|
|||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
auth:
|
auth:
|
||||||
specifier: 1.6.23
|
specifier: 1.6.23
|
||||||
version: 1.6.23(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
version: 1.6.23(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: 0.31.10
|
specifier: 0.31.10
|
||||||
version: 0.31.10
|
version: 0.31.10
|
||||||
@@ -109,7 +109,7 @@ importers:
|
|||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
packages/contracts:
|
packages/contracts:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -137,7 +137,7 @@ importers:
|
|||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
packages/module-sdk:
|
packages/module-sdk:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -159,7 +159,29 @@ importers:
|
|||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
|
packages/module-ui-build:
|
||||||
|
dependencies:
|
||||||
|
'@ekklesia/contracts':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../contracts
|
||||||
|
esbuild:
|
||||||
|
specifier: 0.28.1
|
||||||
|
version: 0.28.1
|
||||||
|
devDependencies:
|
||||||
|
'@biomejs/biome':
|
||||||
|
specifier: 2.5.3
|
||||||
|
version: 2.5.3
|
||||||
|
'@types/node':
|
||||||
|
specifier: 24.13.3
|
||||||
|
version: 24.13.3
|
||||||
|
typescript:
|
||||||
|
specifier: 7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
vitest:
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
packages/module-ui-sdk:
|
packages/module-ui-sdk:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -175,7 +197,7 @@ importers:
|
|||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
packages/typescript-config: {}
|
packages/typescript-config: {}
|
||||||
|
|
||||||
@@ -184,28 +206,49 @@ importers:
|
|||||||
'@ekklesia/module-sdk':
|
'@ekklesia/module-sdk':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/module-sdk
|
version: link:../../packages/module-sdk
|
||||||
|
'@ekklesia/module-ui-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/module-ui-sdk
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: 0.45.2
|
specifier: 0.45.2
|
||||||
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||||
|
react:
|
||||||
|
specifier: 19.2.8
|
||||||
|
version: 19.2.8
|
||||||
|
react-dom:
|
||||||
|
specifier: 19.2.8
|
||||||
|
version: 19.2.8([email protected])
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.5.3
|
specifier: 2.5.3
|
||||||
version: 2.5.3
|
version: 2.5.3
|
||||||
|
'@ekklesia/module-ui-build':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/module-ui-build
|
||||||
'@ekklesia/typescript-config':
|
'@ekklesia/typescript-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/typescript-config
|
version: link:../../packages/typescript-config
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.13.3
|
specifier: 24.13.3
|
||||||
version: 24.13.3
|
version: 24.13.3
|
||||||
|
'@types/react':
|
||||||
|
specifier: 19.2.18
|
||||||
|
version: 19.2.18
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: 19.2.4
|
||||||
|
version: 19.2.4(@types/[email protected])
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: 0.31.10
|
specifier: 0.31.10
|
||||||
version: 0.31.10
|
version: 0.31.10
|
||||||
|
happy-dom:
|
||||||
|
specifier: 20.0.11
|
||||||
|
version: 20.0.11
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 7.0.2
|
specifier: 7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -1044,15 +1087,29 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^19.2.0
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||||
|
|
||||||
'@typescript/[email protected]':
|
'@typescript/[email protected]':
|
||||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
@@ -1333,6 +1390,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1519,6 +1579,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==}
|
resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==}
|
resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
@@ -1761,6 +1825,15 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
|
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.2.8
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||||
engines: {node: '>= 20.19.0'}
|
engines: {node: '>= 20.19.0'}
|
||||||
@@ -1783,6 +1856,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1854,6 +1930,9 @@ packages:
|
|||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
@@ -1947,6 +2026,10 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2634,6 +2717,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
@@ -2644,8 +2731,18 @@ snapshots:
|
|||||||
pg-protocol: 1.15.0
|
pg-protocol: 1.15.0
|
||||||
pg-types: 2.2.0
|
pg-types: 2.2.0
|
||||||
|
|
||||||
|
'@types/[email protected](@types/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.18
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@typescript/[email protected]':
|
'@typescript/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2749,7 +2846,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected](@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))):
|
[email protected](@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/preset-react': 7.29.7(@babel/[email protected])
|
'@babel/preset-react': 7.29.7(@babel/[email protected])
|
||||||
@@ -2759,7 +2856,7 @@ snapshots:
|
|||||||
'@better-auth/utils': 0.4.2
|
'@better-auth/utils': 0.4.2
|
||||||
'@clack/prompts': 0.11.0
|
'@clack/prompts': 0.11.0
|
||||||
'@mrleebo/prisma-ast': 0.13.1
|
'@mrleebo/prisma-ast': 0.13.1
|
||||||
better-auth: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
better-auth: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
||||||
c12: 3.3.4
|
c12: 3.3.4
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
@@ -2803,7 +2900,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))):
|
[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.6.23(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
'@better-auth/core': 1.6.23(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||||
'@better-auth/drizzle-adapter': 1.6.23(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])([email protected](@types/[email protected])([email protected])([email protected]))
|
'@better-auth/drizzle-adapter': 1.6.23(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])([email protected](@types/[email protected])([email protected])([email protected]))
|
||||||
@@ -2826,7 +2923,9 @@ snapshots:
|
|||||||
drizzle-kit: 0.31.10
|
drizzle-kit: 0.31.10
|
||||||
drizzle-orm: 0.45.2(@types/[email protected])([email protected])([email protected])
|
drizzle-orm: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||||
pg: 8.22.0
|
pg: 8.22.0
|
||||||
vitest: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
react: 19.2.8
|
||||||
|
react-dom: 19.2.8([email protected])
|
||||||
|
vitest: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@cloudflare/workers-types'
|
- '@cloudflare/workers-types'
|
||||||
- '@opentelemetry/api'
|
- '@opentelemetry/api'
|
||||||
@@ -2894,6 +2993,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -3015,6 +3116,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
'@types/whatwg-mimetype': 3.0.2
|
||||||
|
whatwg-mimetype: 3.0.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3198,6 +3305,13 @@ snapshots:
|
|||||||
defu: 6.1.7
|
defu: 6.1.7
|
||||||
destr: 2.0.5
|
destr: 2.0.5
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3229,6 +3343,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3306,6 +3422,8 @@ snapshots:
|
|||||||
'@typescript/typescript-win32-arm64': 7.0.2
|
'@typescript/typescript-win32-arm64': 7.0.2
|
||||||
'@typescript/typescript-win32-x64': 7.0.2
|
'@typescript/typescript-win32-x64': 7.0.2
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
@@ -3329,7 +3447,7 @@ snapshots:
|
|||||||
tsx: 4.23.1
|
tsx: 4.23.1
|
||||||
yaml: 2.9.0
|
yaml: 2.9.0
|
||||||
|
|
||||||
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
|
[email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.10
|
'@vitest/expect': 4.1.10
|
||||||
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
@@ -3353,9 +3471,12 @@ snapshots:
|
|||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
|
happy-dom: 20.0.11
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
|
|||||||
@@ -8,12 +8,18 @@ route (`GET /records`, declared in `register`) that reads that table through
|
|||||||
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
|
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
|
||||||
provisioning are intentionally not included.
|
provisioning are intentionally not included.
|
||||||
|
|
||||||
This template ships backend behavior only, which is a choice rather than a constraint: as of
|
It also ships an **optional** React UI (`src/ui/`), compiled to the `entrypoints.ui` artifact its
|
||||||
[ADR 0021](../../docs/architecture/decisions/0021-modules-as-hostable-products.md) a module may also
|
manifest declares by `pnpm build`
|
||||||
declare an optional compiled `entrypoints.ui` artifact. The template does not, because no host loads
|
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). UI is a choice, not
|
||||||
a UI entrypoint yet — see
|
a requirement: to keep a module backend-only, delete `src/ui/`, `tsconfig.ui.json`,
|
||||||
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint) for
|
`tsconfig.ui-test.json`, `tests/module-ui.test.ts`, the `ui` entrypoint from the manifest, and the
|
||||||
what is and is not supported today.
|
React and module-UI dependencies. Everything else keeps working, and `ekklesia-build-module-ui`
|
||||||
|
simply reports that there is nothing to build.
|
||||||
|
|
||||||
|
No host mounts a module UI yet — the Dashboard shell does not exist. `tests/module-ui.test.ts` builds
|
||||||
|
the artifact from source, validates it with `parseModuleUiExport`, mounts it into a real DOM, and
|
||||||
|
unmounts it, which is what proves the pipeline today. See
|
||||||
|
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint).
|
||||||
|
|
||||||
To create a module inside this repository:
|
To create a module inside this repository:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifestVersion": 4,
|
"manifestVersion": 4,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example Module",
|
"name": "Example Module",
|
||||||
"description": "A minimal Ekklesia module template with backend behavior only.",
|
"description": "A minimal Ekklesia module template with backend behavior and optional UI.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.1.0",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"server": "./dist/server/index.js",
|
"server": "./dist/server/index.js",
|
||||||
"schema": "./dist/schema/index.js",
|
"schema": "./dist/schema/index.js",
|
||||||
"migrations": "./drizzle"
|
"migrations": "./drizzle",
|
||||||
|
"ui": "./dist/ui/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,22 +19,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project tsconfig.build.json",
|
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
"migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-template",
|
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-template",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/module-sdk": "workspace:*",
|
"@ekklesia/module-sdk": "workspace:*",
|
||||||
"drizzle-orm": "0.45.2"
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@ekklesia/module-ui-build": "workspace:*",
|
||||||
"@ekklesia/typescript-config": "workspace:*",
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
|
"@types/react": "19.2.18",
|
||||||
|
"@types/react-dom": "19.2.4",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
|
"happy-dom": "20.0.11",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { type ReactElement, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads
|
||||||
|
* everything it needs from the host context and hardcodes no URL, no origin, and no global.
|
||||||
|
*/
|
||||||
|
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
||||||
|
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// Presentation-only gating. The server decides what actually happens; this only decides what the
|
||||||
|
// viewer is offered. See docs/development/security.md.
|
||||||
|
const canRead = context.permissions.has("example.records.read");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canRead) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
// Relative to this module's own API root. Never `/api/v1/modules/example/records`: that prefix
|
||||||
|
// is Ekklesia's private deployment detail, and building it here would make this artifact
|
||||||
|
// unhostable by a standalone host (ADR 0022).
|
||||||
|
void context.api
|
||||||
|
.request("records")
|
||||||
|
.then((response) => (response.ok ? response.json() : []))
|
||||||
|
.then((records: unknown) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRecordCount(Array.isArray(records) ? records.length : 0);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRecordCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [canRead, context.api]);
|
||||||
|
|
||||||
|
if (!canRead) {
|
||||||
|
return <p style={{ margin: 0 }}>You do not have access to example records.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ display: "grid", gap: "0.5rem" }}>
|
||||||
|
<h1 style={{ margin: 0 }}>Example module UI</h1>
|
||||||
|
<p style={{ margin: 0 }} data-testid="record-count">
|
||||||
|
{recordCount === undefined ? "Loading records…" : `${recordCount} record(s)`}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { ExampleApp } from "./ExampleApp.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The module's compiled UI entrypoint. `pnpm build` bundles this file, and everything it imports,
|
||||||
|
* into the single browser artifact this module's manifest declares as `entrypoints.ui`.
|
||||||
|
*
|
||||||
|
* `id` and `version` must match the manifest: a host verifies both before mounting, so a stale
|
||||||
|
* artifact is caught rather than rendered.
|
||||||
|
*/
|
||||||
|
export default defineModuleUi({
|
||||||
|
id: "example",
|
||||||
|
version: "0.1.0",
|
||||||
|
mount(container, context) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<ExampleApp context={context} />);
|
||||||
|
// React owns a root here, so the mount must return an unmount that releases it.
|
||||||
|
return () => root.unmount();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||||
|
import { parseModuleManifest } from "@ekklesia/module-sdk";
|
||||||
|
import {
|
||||||
|
type ModuleUiApiClient,
|
||||||
|
type ModuleUiHostContext,
|
||||||
|
parseModuleUiExport,
|
||||||
|
} from "@ekklesia/module-ui-sdk";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end-to-end proof for the module UI build pipeline (ADR 0023):
|
||||||
|
*
|
||||||
|
* React/TSX source -> `pnpm build` -> compiled artifact at the manifest's `entrypoints.ui`
|
||||||
|
* -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()`
|
||||||
|
*
|
||||||
|
* The artifact is rebuilt from source here rather than read from a checked-in file, so the test can
|
||||||
|
* never pass against stale output. `dist/` is gitignored; nothing generated is committed.
|
||||||
|
*/
|
||||||
|
// `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
|
||||||
|
// this test works in plain filesystem paths and converts to a file URL only for dynamic import.
|
||||||
|
const moduleDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||||
|
|
||||||
|
let artifactHref: string;
|
||||||
|
let artifactPath: string;
|
||||||
|
let manifestUi: string;
|
||||||
|
let manifestIdentity: { id: string; version: string };
|
||||||
|
|
||||||
|
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
|
||||||
|
function recordingHost(options: {
|
||||||
|
readonly canRead: boolean;
|
||||||
|
readonly records: readonly unknown[];
|
||||||
|
readonly requested: string[];
|
||||||
|
readonly resolve: (path: string) => void;
|
||||||
|
}): ModuleUiHostContext {
|
||||||
|
const api: ModuleUiApiClient = {
|
||||||
|
async request(path) {
|
||||||
|
options.requested.push(path);
|
||||||
|
options.resolve(path);
|
||||||
|
return new Response(JSON.stringify(options.records), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
moduleId: "example",
|
||||||
|
api,
|
||||||
|
principal: { kind: "anonymous" },
|
||||||
|
permissions: { has: () => options.canRead },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
// Let React commit and the awaited fetch microtasks settle.
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module UI build pipeline", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const manifest = parseModuleManifest(
|
||||||
|
JSON.parse(
|
||||||
|
await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8"),
|
||||||
|
) as unknown,
|
||||||
|
);
|
||||||
|
if (!manifest.entrypoints.ui) {
|
||||||
|
throw new Error("The template manifest must declare entrypoints.ui for this test.");
|
||||||
|
}
|
||||||
|
manifestUi = manifest.entrypoints.ui;
|
||||||
|
manifestIdentity = { id: manifest.id, version: manifest.version };
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory });
|
||||||
|
artifactPath = result.outputFile;
|
||||||
|
artifactHref = pathToFileURL(result.outputFile).href;
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("writes the artifact exactly where the manifest declares it", () => {
|
||||||
|
expect(manifestUi).toBe("./dist/ui/index.js");
|
||||||
|
expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a browser-safe ESM artifact that needs no host-provided React", async () => {
|
||||||
|
const artifact = await readFile(artifactPath, "utf8");
|
||||||
|
|
||||||
|
// React is bundled, so the artifact resolves nothing at run time (ADR 0023). A bare top-level
|
||||||
|
// import would mean the host had to supply that module.
|
||||||
|
expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
|
||||||
|
expect(artifact).toContain("export {");
|
||||||
|
// No Ekklesia deployment detail may be baked into a portable artifact.
|
||||||
|
expect(artifact).not.toContain("/api/v1/modules");
|
||||||
|
// `process.env` must be substituted at build time; a browser has no `process`.
|
||||||
|
expect(artifact).not.toContain("process.env.NODE_ENV");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("satisfies parseModuleUiExport and matches the manifest identity", async () => {
|
||||||
|
const namespace = (await import(artifactHref)) as unknown;
|
||||||
|
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||||
|
|
||||||
|
expect(definition.id).toBe("example");
|
||||||
|
expect(definition.version).toBe("0.1.0");
|
||||||
|
expect(typeof definition.mount).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts real React into a container, then unmounts and cleans up", async () => {
|
||||||
|
const namespace = (await import(artifactHref)) as unknown;
|
||||||
|
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
const requested: string[] = [];
|
||||||
|
|
||||||
|
const unmount = await definition.mount(
|
||||||
|
container,
|
||||||
|
recordingHost({
|
||||||
|
canRead: true,
|
||||||
|
records: [
|
||||||
|
{ id: "a", value: "one" },
|
||||||
|
{ id: "b", value: "two" },
|
||||||
|
],
|
||||||
|
requested,
|
||||||
|
resolve: () => undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Example module UI");
|
||||||
|
expect(container.querySelector("[data-testid='record-count']")?.textContent).toBe(
|
||||||
|
"2 record(s)",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(typeof unmount).toBe("function");
|
||||||
|
await (unmount as () => Promise<void>)();
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks its host for a relative path, never Ekklesia's module API prefix", async () => {
|
||||||
|
const namespace = (await import(artifactHref)) as unknown;
|
||||||
|
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const requested: string[] = [];
|
||||||
|
const resolvedUrls: string[] = [];
|
||||||
|
|
||||||
|
const unmount = await definition.mount(
|
||||||
|
container,
|
||||||
|
recordingHost({
|
||||||
|
canRead: true,
|
||||||
|
records: [],
|
||||||
|
requested,
|
||||||
|
// A standalone host resolving the same relative path to a completely different URL.
|
||||||
|
resolve: (path) => {
|
||||||
|
resolvedUrls.push(`https://example.test/api/${path}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(requested).toEqual(["records"]);
|
||||||
|
expect(resolvedUrls).toEqual(["https://example.test/api/records"]);
|
||||||
|
await (unmount as () => Promise<void>)();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a denied state from the host's presentation-only permission hint", async () => {
|
||||||
|
const namespace = (await import(artifactHref)) as unknown;
|
||||||
|
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const requested: string[] = [];
|
||||||
|
|
||||||
|
const unmount = await definition.mount(
|
||||||
|
container,
|
||||||
|
recordingHost({ canRead: false, records: [], requested, resolve: () => undefined }),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("do not have access");
|
||||||
|
// A hidden control is a usability choice, not enforcement: the module simply did not ask.
|
||||||
|
// Server-side authorization is what actually protects the route.
|
||||||
|
expect(requested).toEqual([]);
|
||||||
|
await (unmount as () => Promise<void>)();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,5 +4,6 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node", "vitest/globals"]
|
"types": ["node", "vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["drizzle.config.ts", "src/**/*.ts", "tests/**/*.ts"]
|
"include": ["drizzle.config.ts", "src/**/*.ts", "tests/**/*.ts"],
|
||||||
|
"exclude": ["tests/module-ui.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["tests/module-ui.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ describe("repository dependency boundaries", () => {
|
|||||||
["module-ui-framework", "modules-do-not-own-ui"],
|
["module-ui-framework", "modules-do-not-own-ui"],
|
||||||
["module-ui-aliased-framework", "modules-do-not-own-ui"],
|
["module-ui-aliased-framework", "modules-do-not-own-ui"],
|
||||||
["module-ui-imports-core", "modules-use-public-platform-contracts"],
|
["module-ui-imports-core", "modules-use-public-platform-contracts"],
|
||||||
|
["module-ui-bundler-plugin", "modules-do-not-own-ui"],
|
||||||
["module-imports-ui-package", "modules-use-public-platform-contracts"],
|
["module-imports-ui-package", "modules-use-public-platform-contracts"],
|
||||||
["cross-package-relative", "workspace-imports-use-public-exports"],
|
["cross-package-relative", "workspace-imports-use-public-exports"],
|
||||||
["cycle", "no-circular-source-dependencies"],
|
["cycle", "no-circular-source-dependencies"],
|
||||||
@@ -111,12 +112,24 @@ describe("repository dependency boundaries", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a module that owns React UI through the public module UI SDK", async () => {
|
it("accepts the canonical module UI dependency set: React, the UI SDK, and the build tool", async () => {
|
||||||
|
// Mirrors what `templates/module` actually declares (ADR 0023). The build tool is a `tooling`
|
||||||
|
// shared package, a role modules were already permitted to depend on, so the module UI build
|
||||||
|
// pipeline required no boundary relaxation at all.
|
||||||
const result = await checkBoundaries(path.join(fixtures, "module-owns-react-ui"));
|
const result = await checkBoundaries(path.join(fixtures, "module-owns-react-ui"));
|
||||||
|
|
||||||
expect(result.violations).toEqual([]);
|
expect(result.violations).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("still rejects a module reaching for a bundler plugin ecosystem", async () => {
|
||||||
|
// Module authors do not configure a bundler; `@ekklesia/module-ui-build` owns that entirely.
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "module-ui-bundler-plugin"));
|
||||||
|
|
||||||
|
expect(result.violations.map((violation) => violation.message)).toEqual([
|
||||||
|
"Module package depends on UI framework @vitejs/plugin-react.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an unsupported framework aliased behind the supported one", async () => {
|
it("rejects an unsupported framework aliased behind the supported one", async () => {
|
||||||
const result = await checkBoundaries(path.join(fixtures, "module-ui-aliased-framework"));
|
const result = await checkBoundaries(path.join(fixtures, "module-ui-aliased-framework"));
|
||||||
|
|
||||||
|
|||||||
+3
@@ -12,5 +12,8 @@
|
|||||||
},
|
},
|
||||||
"ekklesia": {
|
"ekklesia": {
|
||||||
"layer": "module"
|
"layer": "module"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ekklesia/module-ui-build": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-ui-build",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "tooling"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export function buildModuleUi(): void {}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-calendar",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "5.1.1"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const moduleId = "calendar";
|
||||||
Reference in New Issue
Block a user