Phase 8/module platform prerequisites #40
+7
-5
@@ -35,13 +35,15 @@ pnpm infrastructure:config
|
|||||||
private source paths.
|
private source paths.
|
||||||
- Use `workspace:` ranges for internal package dependencies.
|
- Use `workspace:` ranges for internal package dependencies.
|
||||||
- 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`, and may author
|
||||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||||
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||||
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||||
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
|
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
|
||||||
bundler configuration, and styling is React inline style objects only for now. Both roles use the
|
bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
|
||||||
same UI SDK context with no presentation-mode flag.
|
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
|
||||||
|
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
|
||||||
|
presentation-mode flag, and the same versioned mount ABI.
|
||||||
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
|
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
|
||||||
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
|
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
|
||||||
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
|
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
|
||||||
|
|||||||
@@ -28,11 +28,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@ekklesia/module-ui-host": "workspace:*",
|
"@ekklesia/module-ui-host": "workspace:*",
|
||||||
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
"@ekklesia/typescript-config": "workspace:*",
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/smtp-server": "3.5.13",
|
"@types/smtp-server": "3.5.13",
|
||||||
"better-auth": "1.6.23",
|
"better-auth": "1.6.23",
|
||||||
|
"happy-dom": "20.0.11",
|
||||||
"pg": "8.22.0",
|
"pg": "8.22.0",
|
||||||
"smtp-server": "3.19.3",
|
"smtp-server": "3.19.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import {
|
|||||||
discoverModules,
|
discoverModules,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import {
|
||||||
|
loadModuleUi,
|
||||||
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||||
|
} from "@ekklesia/module-ui-host";
|
||||||
|
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
import {
|
import {
|
||||||
bootstrapDisplayHost,
|
bootstrapDisplayHost,
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
@@ -165,6 +171,7 @@ async function createFixtureModule(
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
@@ -184,6 +191,7 @@ async function createFixtureModule(
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/display/index.js"),
|
path.join(moduleDirectory, "dist/display/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
@@ -310,10 +318,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
|
|||||||
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The loader hands the container straight to the module and never inspects it, so a plain sink is
|
/**
|
||||||
* enough and no DOM implementation is needed in this Node-environment suite. */
|
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
|
||||||
function containerSink(): { textContent: string } {
|
* test environment.
|
||||||
return { textContent: "" };
|
*
|
||||||
|
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
|
||||||
|
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
|
||||||
|
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
|
||||||
|
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
|
||||||
|
* dynamically imports real module server entrypoints from temporary directories, which then fail to
|
||||||
|
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
|
||||||
|
* this server-side suite depends on exactly as Node provides it.
|
||||||
|
*/
|
||||||
|
function containerSink(): HostContainer {
|
||||||
|
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostContainer {
|
||||||
|
readonly children: { readonly length: number };
|
||||||
|
querySelector(selectors: string): HostContainer | null;
|
||||||
|
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||||
|
function mountedText(container: HostContainer): string {
|
||||||
|
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||||
|
""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||||
@@ -365,13 +398,13 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 5. The module rendered, having reached its own backend through the host.
|
// 5. The module rendered, having reached its own backend through the host.
|
||||||
expect(container.textContent).toBe("example:200:2");
|
expect(mountedText(container)).toBe("example:200:2");
|
||||||
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
|
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
|
||||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
|
|
||||||
// 7. And it tears down cleanly.
|
// 7. And it tears down cleanly.
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(mountedText(container)).toBe("");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
@@ -443,10 +476,10 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toBe("example:display:200:2");
|
expect(mountedText(container)).toBe("example:display:200:2");
|
||||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(mountedText(container)).toBe("");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
@@ -514,7 +547,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Hiding a control is a usability improvement: the module simply did not ask.
|
// Hiding a control is a usability improvement: the module simply did not ask.
|
||||||
expect(container.textContent).toBe("example:denied");
|
expect(mountedText(container)).toBe("example:denied");
|
||||||
expect(harness.moduleApiCalls).toEqual([]);
|
expect(harness.moduleApiCalls).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
@@ -593,7 +626,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
version: groupedEntry.descriptor.version,
|
version: groupedEntry.descriptor.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(container.textContent).toBe("example:200:2");
|
expect(mountedText(container)).toBe("example:200:2");
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
|
|
||||||
// Now move it somewhere else entirely.
|
// Now move it somewhere else entirely.
|
||||||
@@ -645,7 +678,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
version: movedEntry.descriptor.version,
|
version: movedEntry.descriptor.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(movedContainer.textContent).toBe("example:200:2");
|
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||||
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||||
"GET /api/v1/modules/example/records",
|
"GET /api/v1/modules/example/records",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||||
|
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
@@ -59,6 +61,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
* sequence whose exact publication and loader-compatibility can be asserted. */
|
* sequence whose exact publication and loader-compatibility can be asserted. */
|
||||||
function artifactSource(id: string, version: string): string {
|
function artifactSource(id: string, version: string): string {
|
||||||
return `export default {
|
return `export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: ${JSON.stringify(version)},
|
version: ${JSON.stringify(version)},
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
@@ -862,6 +865,12 @@ describe("module UI artifact URL generation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface HostContainer {
|
||||||
|
readonly children: { readonly length: number };
|
||||||
|
querySelector(selectors: string): HostContainer | null;
|
||||||
|
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
||||||
/**
|
/**
|
||||||
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
||||||
@@ -880,16 +889,29 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The loader forwards the container untouched to the module's own `mount` and never inspects it,
|
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
||||||
* so a plain sink is enough and no DOM implementation is required. The container's type is read
|
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
|
||||||
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
|
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
|
||||||
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
|
* DOM type here would be the same browser/server confusion this route exists to keep apart.
|
||||||
* route exists to keep apart.
|
|
||||||
*/
|
*/
|
||||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||||
|
|
||||||
function containerSink(): { textContent: string } {
|
/**
|
||||||
return { textContent: "" };
|
* `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
|
||||||
|
* real isolated root inside the container, and installing a DOM environment here would replace
|
||||||
|
* the module runner's resolution for a server-side suite that dynamically imports real module
|
||||||
|
* entrypoints.
|
||||||
|
*/
|
||||||
|
function containerSink(): HostContainer {
|
||||||
|
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||||
|
function mountedText(container: HostContainer): string {
|
||||||
|
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostContext() {
|
function hostContext() {
|
||||||
@@ -915,9 +937,9 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
source: { moduleId: "announcements", url, version: "1.4.2" },
|
source: { moduleId: "announcements", url, version: "1.4.2" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toBe("announcements:mounted");
|
expect(mountedText(container)).toBe("announcements:mounted");
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(container.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still rejects the published artifact when the host expects a different identity", async () => {
|
it("still rejects the published artifact when the host expects a different identity", async () => {
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ This is deliberately the smallest viable styling story. A real stylesheet contra
|
|||||||
field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing
|
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.
|
them here would expand the manifest and runtime contract before a host can validate them.
|
||||||
|
|
||||||
|
> **Correction — this rule is superseded.**
|
||||||
|
> [ADR 0040](0040-module-stylesheets-and-artifact-isolation.md) supplies the three missing pieces and
|
||||||
|
> reverses this one decision: a module may import plain `.css` from a browser-artifact source root,
|
||||||
|
> the canonical builder compiles it **into** the declared artifact as text (so no sidecar is ever
|
||||||
|
> emitted, which is what this section was protecting), and the host installs it into a Shadow DOM
|
||||||
|
> root it creates for every artifact. No manifest field was needed after all. The rest of this ADR —
|
||||||
|
> esbuild, bundled React, fixed source paths, no author bundler configuration, unminified output — is
|
||||||
|
> unchanged.
|
||||||
|
|
||||||
### Browser TypeScript stays scoped to UI source
|
### Browser TypeScript stays scoped to UI source
|
||||||
|
|
||||||
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
|
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# 0040: Module stylesheets, and every browser artifact gets an isolated root
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Supersedes: none
|
||||||
|
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||||
|
[ADR 0023](0023-module-ui-build-pipeline.md),
|
||||||
|
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[ADR 0039](0039-shared-design-tokens-and-elements.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0023 rejected stylesheet imports in module UI, and gave an honest reason: esbuild would emit a
|
||||||
|
`.css` sidecar beside the artifact, nothing would declare it, no host would fetch it, and it would
|
||||||
|
*work in a local test and silently do nothing in a real host*. Inline style objects were the whole
|
||||||
|
supported story, and a stylesheet contract was deferred to "a manifest field, a host loading
|
||||||
|
mechanism, and a style-isolation decision, none of which exist yet."
|
||||||
|
|
||||||
|
Two of those three now exist or are unnecessary, and the remaining gap is blocking real work:
|
||||||
|
|
||||||
|
- Inline style objects cannot express `:hover`, `:focus-visible`, `:disabled`, `@media`, or
|
||||||
|
`@keyframes`. A production management screen needs all of them, and an accessible focus treatment
|
||||||
|
needs `:focus-visible` specifically. This is not a preference; it is a capability inline styles do
|
||||||
|
not have.
|
||||||
|
- ADR 0039's shared elements are built from real CSS for exactly that reason, so a module using them
|
||||||
|
already needs somewhere for a stylesheet to live.
|
||||||
|
- A contributed renderer (a later ADR) makes the isolation question urgent rather than theoretical:
|
||||||
|
one module's renderer mounted inside another module's screen is precisely where a leaked selector
|
||||||
|
does damage nobody can trace.
|
||||||
|
|
||||||
|
The manifest field turns out not to be needed, and the loading mechanism and the isolation decision
|
||||||
|
turn out to be the same decision.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### A stylesheet is compiled **into** the declared artifact, as text
|
||||||
|
|
||||||
|
A module imports its local `.css` files. `@ekklesia/module-ui-build` compiles each one — resolving
|
||||||
|
local `@import`s through a nested bundle — into a JavaScript module whose default export is that
|
||||||
|
stylesheet's text. The author passes the text to `defineModuleUi({ styles })`.
|
||||||
|
|
||||||
|
That is why no manifest field is required. There is still exactly **one** declared file per browser
|
||||||
|
role, containing everything: nothing new to publish, nothing for a host to fetch, no second URL, and
|
||||||
|
no way for a stylesheet to become an undeclared sidecar — the failure ADR 0023 named. The builder
|
||||||
|
additionally fails if any CSS output file is ever produced beside an artifact, so a future option or
|
||||||
|
loader change cannot quietly reintroduce one.
|
||||||
|
|
||||||
|
Two rules are enforced on the compiled text, each with its own sanitized error code:
|
||||||
|
|
||||||
|
- **A module stylesheet may reference Ekklesia's global theme variables and must never declare one.**
|
||||||
|
Declaring `--ekklesia-*` would invent a global outside the registry a future theme editor
|
||||||
|
enumerates (ADR 0039). A private alias under the module's own prefix is the supported way to name
|
||||||
|
a local value, and is explicitly allowed.
|
||||||
|
- **An `@import` that survives compilation is rejected.** A local import is inlined and leaves
|
||||||
|
nothing behind, so anything remaining is a remote or unresolvable reference, and an artifact that
|
||||||
|
fetches a stylesheet at run time is not self-contained.
|
||||||
|
|
||||||
|
Plain `.css` only. `.scss`, `.less`, `.styl`, and friends stay rejected: adding a preprocessor is a
|
||||||
|
language and toolchain decision needing its own ADR, not a detail of a build pass.
|
||||||
|
|
||||||
|
`pnpm boundaries` allows `.css` inside a module **only** under the browser-artifact source roots the
|
||||||
|
canonical builder actually compiles (`src/ui/`, `src/display/`, and the contribution sources a later
|
||||||
|
ADR adds). Anywhere else in a module a stylesheet is a file no build reads and no host loads — the
|
||||||
|
undeclared sidecar again, just relocated. Every other stylesheet dialect stays rejected everywhere.
|
||||||
|
|
||||||
|
### Every browser artifact mounts inside an isolated root
|
||||||
|
|
||||||
|
The generic loader creates a Shadow DOM root inside the host's container, installs the artifact's
|
||||||
|
stylesheet there, and hands the artifact an element inside that root:
|
||||||
|
|
||||||
|
```text
|
||||||
|
host container ← the host's element; the loader never mutates it beyond this one child
|
||||||
|
└─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
|
||||||
|
#shadow-root (open)
|
||||||
|
├─ <style data-ekklesia-module-ui-styles> base rules, then the artifact's declared CSS
|
||||||
|
└─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
|
||||||
|
```
|
||||||
|
|
||||||
|
Shadow DOM is chosen because it is the one mechanism in a browser that **enforces** the rule rather
|
||||||
|
than describing it: rules inside a shadow root do not apply outside it, and document rules do not
|
||||||
|
apply inside it. A class-name convention, a build-time prefix, or a "please scope your selectors"
|
||||||
|
instruction are conventions that one mistake defeats, and the failure mode is a module silently
|
||||||
|
restyling the Dashboard or another module's renderer.
|
||||||
|
|
||||||
|
This applies to **every** role — primary UI, display UI, and contributed renderers — unconditionally,
|
||||||
|
including artifacts that declare no stylesheet. One mounting path, not a styled one and an unstyled
|
||||||
|
one, because a conditional isolation boundary is one a module can accidentally fall out of.
|
||||||
|
|
||||||
|
Both wrapper elements are `display: contents` through a base stylesheet the loader installs *before*
|
||||||
|
the artifact's own, so the loader's two elements contribute no boxes and an artifact lays out exactly
|
||||||
|
as it did before isolation existed — and an artifact that genuinely needs a box (a full-viewport
|
||||||
|
display) overrides `:host` by ordinary cascade order rather than by anything the host special-cases.
|
||||||
|
|
||||||
|
`mode: "open"` is deliberate. **This is not a security boundary, and must not be described as one.**
|
||||||
|
An installed module is trusted first-party code running in the host's own realm (ADR 0022, ADR 0024);
|
||||||
|
it could reach `document` directly if it wanted to. `closed` would buy no protection while making the
|
||||||
|
artifact's DOM unreachable to the host's own tests and diagnostics. This is a correctness boundary
|
||||||
|
for *styles*.
|
||||||
|
|
||||||
|
Design tokens still reach the artifact: CSS custom properties inherit through a shadow boundary, so a
|
||||||
|
host that installed the theme at its root (ADR 0039) has already supplied every artifact on the page.
|
||||||
|
|
||||||
|
### Mount and unmount are deterministic, in both directions
|
||||||
|
|
||||||
|
Each mount builds a fresh root with exactly one `<style>`. Unmount runs the module's cleanup and then
|
||||||
|
removes the whole root — content and styles together — in a `finally`, so a module whose cleanup
|
||||||
|
throws still has its stylesheet taken out of the page. Remounting cannot accumulate a second copy
|
||||||
|
because there is nothing left to accumulate onto. A mount that throws disposes the root it built, so
|
||||||
|
a failed artifact leaves neither partial DOM nor installed styles behind.
|
||||||
|
|
||||||
|
### The mount ABI is versioned, and the cutover is hard
|
||||||
|
|
||||||
|
An artifact's DOM is no longer a direct child of the host's element, and the definition gained a
|
||||||
|
field. That is observable, so `MODULE_UI_ABI_VERSION` is now `2` and `defineModuleUi` stamps it —
|
||||||
|
the author never writes it, so it can never drift from the SDK the artifact actually compiled
|
||||||
|
against.
|
||||||
|
|
||||||
|
There is no dual acceptance, exactly as there is none for a manifest version (ADR 0006, ADR 0014,
|
||||||
|
ADR 0021, ADR 0033). A version-1 artifact fails with `MODULE_UI_ABI_UNSUPPORTED` — checked first and
|
||||||
|
on its own, so the message is "rebuild your module" rather than a developer hunting for a typo.
|
||||||
|
Migration is one `pnpm build`: the canonical builder owns every option, and a module that declares no
|
||||||
|
stylesheet needs no source change at all.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Module UI can express hover, focus-visible, disabled, media queries, and keyframes, so ADR 0039's
|
||||||
|
accessible focus treatment actually works inside a module artifact.
|
||||||
|
- One module's stylesheet provably cannot reach the Dashboard, another module, or another renderer,
|
||||||
|
and the Dashboard's cannot reach in. Two artifacts of the *same* module are isolated from each
|
||||||
|
other too: they share styles only if they share a source file.
|
||||||
|
- A host's own tests reach an artifact's DOM through its open root rather than through
|
||||||
|
`container.querySelector`. Every mounting test in this repository was updated accordingly; that
|
||||||
|
churn is the visible price of the boundary being real.
|
||||||
|
- Every module must be rebuilt once. Nothing else changes for an author who uses no stylesheet.
|
||||||
|
- The artifact is larger by the size of its CSS, exactly as it is larger by the size of React
|
||||||
|
(ADR 0023). It remains one file.
|
||||||
|
- A module still cannot reference a bundled image or font from CSS: no asset loader is configured, so
|
||||||
|
such a reference fails the build. Data URLs work; object storage remains the answer for real media.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keep inline styles only.** Rejected: it cannot express the pseudo-classes an accessible control
|
||||||
|
needs, and the workaround — JavaScript hover and focus tracking — is worse in every way.
|
||||||
|
- **Publish a `.css` sidecar and have the host `<link>` it.** Rejected: it needs a manifest field, a
|
||||||
|
second publication route, a second cache policy, and a second failure mode, and it reintroduces the
|
||||||
|
exact "a file nothing declares" hazard ADR 0023 refused. Compiling into the one declared artifact
|
||||||
|
costs none of that.
|
||||||
|
- **Scope styles by build-time class prefixing instead of Shadow DOM.** Rejected: a convention a
|
||||||
|
single unprefixed selector defeats, with a silent failure mode and no way for the host to prove
|
||||||
|
containment. It also cannot stop the *host's* rules from reaching into an artifact.
|
||||||
|
- **Isolate only artifacts that declare a stylesheet.** Rejected: an artifact would fall in and out
|
||||||
|
of isolation as a source file changed, and a module rendering the shared elements' `<style>` from
|
||||||
|
inside React would leak into the host document without ever importing a `.css` file.
|
||||||
|
- **`mode: "closed"`.** Rejected as security theatre: the artifact is same-realm trusted code, and
|
||||||
|
closing the root only blinds the host's own diagnostics.
|
||||||
|
- **Accept both ABI versions.** Rejected for consistency with every manifest cutover this repository
|
||||||
|
has made: dual acceptance means one artifact can be read two ways, and the migration here is a
|
||||||
|
rebuild rather than an edit.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
CSS preprocessors, CSS Modules, bundled image and font assets, `adoptedStyleSheets` for shared
|
||||||
|
constructable stylesheets across artifacts, a host-supplied reset inside artifacts beyond the two
|
||||||
|
layout rules, and any claim that Shadow DOM confines module *code*.
|
||||||
@@ -48,3 +48,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||||
|
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
|
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
|
||||||
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
|
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
|
||||||
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
|
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
|
||||||
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
|
`@ekklesia/module-ui-sdk`. Since
|
||||||
|
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
|
||||||
|
`.css`, but **only** inside a browser-artifact source root the canonical builder compiles
|
||||||
|
(`src/ui/`, `src/display/`); a stylesheet anywhere else in a module is a file no build reads and no
|
||||||
|
host loads. It still cannot contain `.jsx`, other stylesheet dialects, 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 any shared role
|
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
|
||||||
beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
||||||
@@ -51,6 +55,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
Node's global declarations — into browser code. Core still owns the containing
|
Node's global declarations — into browser code. Core still owns the containing
|
||||||
application shell and all platform policy, and module UI reaches the host only through
|
application shell and all platform policy, and module UI reaches the host only through
|
||||||
`@ekklesia/module-ui-sdk`.
|
`@ekklesia/module-ui-sdk`.
|
||||||
|
- A module stylesheet is compiled **into** the one declared JavaScript artifact as text and installed
|
||||||
|
by the host at mount; there is no sidecar file, no second publication route, and no manifest field
|
||||||
|
for one. Every browser artifact is mounted inside its own Shadow DOM root the generic loader
|
||||||
|
creates, so one module's styles cannot reach the Dashboard, another module, or another renderer,
|
||||||
|
and the host's cannot reach in. That root is a correctness boundary for styles, never a sandbox:
|
||||||
|
module code remains trusted, same-realm code. The browser mount ABI is versioned
|
||||||
|
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
|
||||||
|
semantics.
|
||||||
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||||
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
||||||
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
||||||
|
|||||||
@@ -471,20 +471,105 @@ Every other shared package stays rejected inside a module: `@ekklesia/api-client
|
|||||||
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
|
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
|
||||||
import, and `pnpm boundaries` says so.
|
import, and `pnpm boundaries` says so.
|
||||||
|
|
||||||
### Styling: inline style objects only, for now
|
### Styling: a stylesheet compiled into your artifact
|
||||||
|
|
||||||
Style with React's `style` prop:
|
Author plain `.css` beside your UI source, import it, and declare it
|
||||||
|
([ADR 0040](../architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)):
|
||||||
|
|
||||||
```tsx
|
```text
|
||||||
<section style={{ display: "grid", gap: "0.5rem" }}>…</section>
|
src/ui/index.tsx
|
||||||
|
src/ui/styles.css
|
||||||
```
|
```
|
||||||
|
|
||||||
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
|
```tsx
|
||||||
an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no
|
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||||
manifest field declares it and no host loads it — so it would work in your tests and silently do
|
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||||
nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and
|
import styles from "./styles.css";
|
||||||
a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for
|
|
||||||
this phase.
|
export default defineModuleUi({
|
||||||
|
id: "announcements",
|
||||||
|
version: "0.1.0",
|
||||||
|
styles: `${EKKLESIA_UI_STYLESHEET}\n${styles}`,
|
||||||
|
mount(container, context) { /* … */ },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
TypeScript needs to know what a `.css` import is. Copy the template's `src/module-styles.d.ts` into
|
||||||
|
your module and include it in your UI TypeScript project:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
declare module "*.css" {
|
||||||
|
const stylesheet: string;
|
||||||
|
export default stylesheet;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**There is no sidecar file.** `@ekklesia/module-ui-build` compiles each stylesheet — resolving local
|
||||||
|
`@import`s — into your declared JavaScript artifact as text. One declared file still contains
|
||||||
|
everything, so there is nothing extra to publish, nothing for a host to fetch, and no way for your
|
||||||
|
CSS to become an asset nobody serves. That was the exact hazard that kept stylesheets out until now.
|
||||||
|
|
||||||
|
Two rules are enforced by the build, each with its own error:
|
||||||
|
|
||||||
|
- **Reference Ekklesia's global theme variables; never declare one.** `--ekklesia-*` belongs to the
|
||||||
|
registry a future theme editor enumerates (ADR 0039). Derive private aliases under your own prefix
|
||||||
|
freely: `--announcements-slide-gap: var(--ekklesia-spacing-sm)`.
|
||||||
|
- **Every `@import` must resolve to a local file.** A remote import would make your artifact fetch a
|
||||||
|
stylesheet at run time, and a declared artifact has to be self-contained.
|
||||||
|
|
||||||
|
Plain `.css` only. `.scss`, `.less`, and other dialects stay rejected — adding a preprocessor is a
|
||||||
|
toolchain decision that needs its own ADR. A CSS `url()` pointing at a local image or font also fails
|
||||||
|
the build: no asset loader is configured. Use a data URL, or serve real media from your module's
|
||||||
|
object storage.
|
||||||
|
|
||||||
|
`pnpm boundaries` allows `.css` inside `src/ui/` and `src/display/` only. A stylesheet anywhere else
|
||||||
|
in your module is a file no build reads and no host loads.
|
||||||
|
|
||||||
|
### Your artifact renders in its own isolated root
|
||||||
|
|
||||||
|
Every browser artifact — primary UI, display UI, and any renderer you contribute — is mounted inside
|
||||||
|
a Shadow DOM root the host creates, with your stylesheet installed in it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
host container
|
||||||
|
└─ <div data-ekklesia-module-ui>
|
||||||
|
#shadow-root (open)
|
||||||
|
├─ <style> host base rules, then your declared CSS
|
||||||
|
└─ <div> ← the container your mount() receives
|
||||||
|
```
|
||||||
|
|
||||||
|
What that buys you, and what it costs:
|
||||||
|
|
||||||
|
- **Your selectors cannot leak out**, and the Dashboard's cannot leak in. Write `.slide` without a
|
||||||
|
prefix and without worrying about another module's `.slide`.
|
||||||
|
- **Design tokens still reach you.** CSS custom properties inherit through the boundary, so
|
||||||
|
`var(--ekklesia-…)` resolves from the values the host installed at its root. Nothing to import at
|
||||||
|
run time, nothing for the host to pass.
|
||||||
|
- **Two artifacts of your own module are isolated from each other too.** Your management UI and your
|
||||||
|
display share styles only if they share a source file.
|
||||||
|
- **Your DOM is not in the host document's tree.** `document.querySelector` from outside will not
|
||||||
|
find your elements — including in your own tests, which should mount the artifact and query inside
|
||||||
|
the root the loader hands `mount`.
|
||||||
|
- **You get no box by default.** The host's two wrapper elements are `display: contents`, so you lay
|
||||||
|
out exactly where the host put you. A full-viewport display that needs a real box overrides it:
|
||||||
|
`:host { display: block }`, which works because the host installs its base rules *before* yours.
|
||||||
|
- **Mount and unmount are deterministic.** The host installs your stylesheet once per mount and
|
||||||
|
removes the whole root on unmount, so repeated mounts never accumulate styles and a cleanup that
|
||||||
|
throws still cannot leave your CSS behind. You never install or remove your own `<style>`.
|
||||||
|
|
||||||
|
**This is not a sandbox.** Your artifact is trusted first-party code in the host's own JavaScript
|
||||||
|
realm and can reach `document` if it wants to; the root is `open` for exactly that reason. It is a
|
||||||
|
correctness boundary for styles, and nothing more. See
|
||||||
|
[Security model and deployment trust boundaries](../development/security.md).
|
||||||
|
|
||||||
|
### The mount ABI is versioned
|
||||||
|
|
||||||
|
`defineModuleUi` stamps the browser mount ABI version onto your artifact — you never write it. A host
|
||||||
|
verifies it before mounting, and an artifact built against an older SDK fails with
|
||||||
|
`MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics.
|
||||||
|
|
||||||
|
Migrating from ABI 1 is one `pnpm build`. If you declare no stylesheet, no source change is needed at
|
||||||
|
all; if your own tests assert on module DOM, they need to look inside the isolated root.
|
||||||
|
|
||||||
### What you may and may not use today
|
### What you may and may not use today
|
||||||
|
|
||||||
@@ -493,7 +578,7 @@ the build pipeline** — adding the pipeline required no boundary relaxation at
|
|||||||
|
|
||||||
| 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, and plain `.css` inside `src/ui/` or `src/display/` | `.jsx` (strict TypeScript only), `.css` outside a browser source root, `.scss`/`.less`/`.styl`, `.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/module-ui-build`, `@ekklesia/design-tokens`, `@ekklesia/ui` | Every other shared role: `api-client`, `auth-client`, `surface-sdk`, `module-ui-host` |
|
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build`, `@ekklesia/design-tokens`, `@ekklesia/ui` | Every other shared role: `api-client`, `auth-client`, `surface-sdk`, `module-ui-host` |
|
||||||
|
|
||||||
@@ -581,8 +666,9 @@ Four things about it matter to you as a module author:
|
|||||||
- **Only the artifact that role declares is served.** The module id and version in either URL are
|
- **Only the artifact that role declares is served.** The module id and version in either URL are
|
||||||
lookup keys, never path components — there is no way to request a second file from your package.
|
lookup keys, never path components — there is no way to request a second file from your package.
|
||||||
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
|
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
|
||||||
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
|
an asset, it must be inside the bundle: that is exactly why your stylesheet is compiled into the
|
||||||
stylesheet import still fails the build.
|
artifact as text rather than published beside it (ADR 0040), and why a bundled image or font is
|
||||||
|
still not supported.
|
||||||
- **The version must match exactly.** A request for any other version is refused rather than served
|
- **The version must match exactly.** A request for any other version is refused rather than served
|
||||||
your installed artifact, so a stale host cannot mount something it did not ask for.
|
your installed artifact, so a stale host cannot mount something it did not ask for.
|
||||||
- **Your artifact bytes are public.** These two routes require no authentication — fetching code
|
- **Your artifact bytes are public.** These two routes require no authentication — fetching code
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export type ModuleUiBuildErrorCode =
|
|||||||
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
||||||
| "MODULE_UI_ENTRYPOINT_COLLISION"
|
| "MODULE_UI_ENTRYPOINT_COLLISION"
|
||||||
| "MODULE_UI_SOURCE_MISSING"
|
| "MODULE_UI_SOURCE_MISSING"
|
||||||
|
| "MODULE_UI_STYLESHEET_INVALID"
|
||||||
|
| "MODULE_UI_STYLESHEET_UNDECLARED"
|
||||||
| "MODULE_UI_BUILD_FAILED";
|
| "MODULE_UI_BUILD_FAILED";
|
||||||
|
|
||||||
export type ModuleUiArtifactKind = "ui" | "display";
|
export type ModuleUiArtifactKind = "ui" | "display";
|
||||||
@@ -34,6 +36,10 @@ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.f
|
|||||||
MODULE_UI_ENTRYPOINT_COLLISION:
|
MODULE_UI_ENTRYPOINT_COLLISION:
|
||||||
"A display UI entrypoint must not overwrite another declared artifact.",
|
"A display UI entrypoint must not overwrite another declared artifact.",
|
||||||
MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.",
|
MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.",
|
||||||
|
MODULE_UI_STYLESHEET_INVALID:
|
||||||
|
"A module stylesheet may not define a global Ekklesia theme variable or keep an unresolved @import.",
|
||||||
|
MODULE_UI_STYLESHEET_UNDECLARED:
|
||||||
|
"A module UI build produced a stylesheet beside its artifact. Every style must be compiled into the declared artifact.",
|
||||||
MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.",
|
MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,32 +64,97 @@ export class ModuleUiBuildError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rejects a stylesheet import instead of quietly emitting one.
|
* A CSS custom property in Ekklesia's own global theme namespace, being **declared** rather than
|
||||||
*
|
* referenced. `var(--ekklesia-x)` and `var(--ekklesia-x, fallback)` are reads and are fine; only a
|
||||||
* esbuild would happily bundle `import "./styles.css"` into a sidecar `index.css` next to the
|
* `--ekklesia-x:` declaration matches, because that is what would invent a global theme variable
|
||||||
* artifact. Nothing declares that file, no host loads it, and the manifest has no field for it — so
|
* outside the registry a future theme editor enumerates (ADR 0039).
|
||||||
* 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 {
|
const GLOBAL_THEME_DECLARATION = /--ekklesia-[a-z0-9-]*\s*:/iu;
|
||||||
|
|
||||||
|
/** An `@import` that survived compilation — a remote or otherwise unresolvable one. A local import
|
||||||
|
* is inlined by the nested bundle below and leaves nothing behind, so anything still here would make
|
||||||
|
* the artifact fetch a stylesheet at run time, which is exactly what "self-contained" excludes. */
|
||||||
|
const SURVIVING_IMPORT = /@import\s/iu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles each imported stylesheet into the artifact instead of emitting a sidecar.
|
||||||
|
*
|
||||||
|
* esbuild's default for `import "./styles.css"` from JavaScript is to collect every such stylesheet
|
||||||
|
* into an `index.css` output beside the artifact. Nothing declares that file, no host fetches it,
|
||||||
|
* and the manifest has no field for it — it would work in a local test and silently do nothing in a
|
||||||
|
* real host. ADR 0023 therefore rejected the import outright; ADR 0040 compiles it instead.
|
||||||
|
*
|
||||||
|
* Each `.css` import becomes a JavaScript module whose default export is that stylesheet's compiled
|
||||||
|
* text, produced by a nested esbuild run so local `@import`s are resolved and the result is one
|
||||||
|
* self-contained string. The author passes that string to `defineModuleUi({ styles })` and the host
|
||||||
|
* installs it into the artifact's isolated root. The declared `.js` artifact therefore remains the
|
||||||
|
* only file the platform publishes, exactly as before stylesheets existed.
|
||||||
|
*
|
||||||
|
* Other stylesheet dialects stay rejected. Adding one is a language and toolchain decision, not a
|
||||||
|
* detail of this pass, and would need its own ADR.
|
||||||
|
*/
|
||||||
|
const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports";
|
||||||
|
|
||||||
|
function compileStylesheetImports(): Plugin {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
return {
|
return {
|
||||||
name: "ekklesia-reject-stylesheet-imports",
|
name: STYLESHEET_PLUGIN_NAME,
|
||||||
setup(pluginBuild) {
|
setup(pluginBuild) {
|
||||||
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
|
// 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
|
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). These patterns are
|
||||||
// pure ASCII, so the flag would change nothing anyway.
|
// pure ASCII, so the flag would change nothing anyway.
|
||||||
pluginBuild.onResolve({ filter: /\.(css|s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
|
pluginBuild.onResolve({ filter: /\.(s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
text:
|
text:
|
||||||
"Module UI cannot import a stylesheet yet: there is no declared way for a host to " +
|
"Module UI supports plain .css only. Other stylesheet dialects have no compilation " +
|
||||||
"load one. Use React inline style objects for now (ADR 0023).",
|
"contract in this platform (ADR 0040).",
|
||||||
location: { file: args.importer },
|
location: { file: args.importer },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => {
|
||||||
|
const compiled = await build({
|
||||||
|
bundle: true,
|
||||||
|
entryPoints: [args.path],
|
||||||
|
logLevel: "silent",
|
||||||
|
minify: false,
|
||||||
|
target: MODULE_UI_BUILD_TARGET,
|
||||||
|
write: false,
|
||||||
|
});
|
||||||
|
const stylesheet = compiled.outputFiles
|
||||||
|
.map((outputFile) => decoder.decode(outputFile.contents))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if (GLOBAL_THEME_DECLARATION.test(stylesheet)) {
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
"A module stylesheet may reference Ekklesia's global theme variables but must " +
|
||||||
|
"never declare one. Derive a private alias under your own prefix instead " +
|
||||||
|
"(ADR 0039).",
|
||||||
|
location: { file: args.path },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (SURVIVING_IMPORT.test(stylesheet)) {
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
"A module stylesheet left an unresolved @import. A declared artifact must be " +
|
||||||
|
"self-contained, so every import has to resolve to a local file (ADR 0040).",
|
||||||
|
location: { file: args.path },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { contents: `export default ${JSON.stringify(stylesheet)};`, loader: "js" };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -237,7 +308,7 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
|
|||||||
minify: false,
|
minify: false,
|
||||||
outfile: artifact.outputFile,
|
outfile: artifact.outputFile,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
plugins: [rejectStylesheetImports()],
|
plugins: [compileStylesheetImports()],
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
target: MODULE_UI_BUILD_TARGET,
|
target: MODULE_UI_BUILD_TARGET,
|
||||||
// Fail rather than silently shipping a partial bundle.
|
// Fail rather than silently shipping a partial bundle.
|
||||||
@@ -246,6 +317,17 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
|
|||||||
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
|
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
|
||||||
write: false,
|
write: false,
|
||||||
});
|
});
|
||||||
|
// Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into
|
||||||
|
// JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or
|
||||||
|
// loader change could reintroduce a sidecar, and a sidecar is precisely the failure this
|
||||||
|
// design exists to make impossible: a file nothing declares, no host fetches, and every local
|
||||||
|
// test silently passes without. Producing one fails the build rather than shipping it.
|
||||||
|
const sidecar = result.outputFiles.find(
|
||||||
|
(outputFile) => path.extname(outputFile.path).toLowerCase() === ".css",
|
||||||
|
);
|
||||||
|
if (sidecar) {
|
||||||
|
throw new ModuleUiBuildError("MODULE_UI_STYLESHEET_UNDECLARED", artifact.kind);
|
||||||
|
}
|
||||||
pendingWrites.push(
|
pendingWrites.push(
|
||||||
result.outputFiles.map((outputFile) => ({
|
result.outputFiles.map((outputFile) => ({
|
||||||
contents: outputFile.contents,
|
contents: outputFile.contents,
|
||||||
@@ -253,10 +335,21 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof ModuleUiBuildError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
|
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
|
||||||
// programmatic caller never receives raw paths or internals.
|
// programmatic caller never receives raw paths or internals.
|
||||||
if (isRecord(error) && Array.isArray(error.errors)) {
|
if (isRecord(error) && Array.isArray(error.errors)) {
|
||||||
throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind), {
|
// A stylesheet the module authored broke one of this platform's own rules, rather than
|
||||||
|
// failing to compile. Reported with its own code so a caller can tell "your CSS is not
|
||||||
|
// allowed to do that" from "your TSX does not compile".
|
||||||
|
const code = error.errors.some(
|
||||||
|
(diagnostic) => isRecord(diagnostic) && diagnostic.pluginName === STYLESHEET_PLUGIN_NAME,
|
||||||
|
)
|
||||||
|
? "MODULE_UI_STYLESHEET_INVALID"
|
||||||
|
: "MODULE_UI_BUILD_FAILED";
|
||||||
|
throw Object.assign(new ModuleUiBuildError(code, artifact.kind), {
|
||||||
diagnostics: error.errors,
|
diagnostics: error.errors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,10 +342,10 @@ describe("module UI build", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
"styles.css",
|
|
||||||
"theme.scss",
|
"theme.scss",
|
||||||
"layout.less",
|
"layout.less",
|
||||||
])("rejects a %s import rather than emitting an undeclared sidecar asset", async (stylesheet) => {
|
"tokens.styl",
|
||||||
|
])("rejects a %s import: only plain CSS has a compilation contract", async (stylesheet) => {
|
||||||
const directory = await createModule({
|
const directory = await createModule({
|
||||||
ui: "./dist/ui/index.js",
|
ui: "./dist/ui/index.js",
|
||||||
files: {
|
files: {
|
||||||
@@ -358,47 +358,131 @@ describe("module UI build", () => {
|
|||||||
await buildModuleUi({ moduleDirectory: directory });
|
await buildModuleUi({ moduleDirectory: directory });
|
||||||
expect.unreachable("expected the stylesheet import to be rejected");
|
expect.unreachable("expected the stylesheet import to be rejected");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
|
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
|
||||||
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
|
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
|
||||||
expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe(
|
expect(diagnostics.some((entry) => entry.text?.includes("plain .css only"))).toBe(true);
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never leaves a stylesheet sidecar next to the artifact", async () => {
|
it("compiles a plain stylesheet into the artifact instead of emitting a sidecar", async () => {
|
||||||
const directory = await createModule({
|
const directory = await createModule({
|
||||||
ui: "./dist/ui/index.js",
|
ui: "./dist/ui/index.js",
|
||||||
files: {
|
files: {
|
||||||
"src/ui/index.tsx": 'import "./styles.css";\nexport default {};',
|
"src/ui/index.tsx": `
|
||||||
"src/ui/styles.css": ".example { color: red; }",
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
"src/ui/styles.css": ".example { color: var(--ekklesia-text-primary); }",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toBeInstanceOf(
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
ModuleUiBuildError,
|
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
|
||||||
);
|
|
||||||
|
// The whole stylesheet travels inside the one declared artifact.
|
||||||
|
expect(artifact).toContain(".example");
|
||||||
|
expect(artifact).toContain("--ekklesia-text-primary");
|
||||||
|
// And nothing was emitted beside it. A sidecar is the exact failure this design prevents: a
|
||||||
|
// file nothing declares, no host fetches, and every local test silently passes without.
|
||||||
await expect(
|
await expect(
|
||||||
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
|
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enforces the stylesheet boundary for display UI too", async () => {
|
it("resolves a local @import so the compiled stylesheet is self-contained", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
"src/ui/styles.css": '@import "./base.css";\n.example { color: red; }',
|
||||||
|
"src/ui/base.css": ".base { display: grid; }",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
|
||||||
|
|
||||||
|
expect(artifact).toContain(".base");
|
||||||
|
expect(artifact).toContain(".example");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stylesheet that keeps an unresolved @import, so the artifact stays self-contained", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
"src/ui/styles.css": '@import url("https://fonts.example.invalid/family.css");',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_STYLESHEET_INVALID",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stylesheet that declares a global Ekklesia theme variable", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
// Referencing a token is the contract; *declaring* one would invent a global theme variable
|
||||||
|
// outside the registry a future theme editor enumerates (ADR 0039).
|
||||||
|
"src/ui/styles.css": ":host { --ekklesia-text-primary: hotpink; }",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
expect.unreachable("expected the stylesheet to be rejected");
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
|
||||||
|
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
|
||||||
|
expect(diagnostics.some((entry) => entry.text?.includes("never declare one"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a private alias derived from a registered token", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
"src/ui/styles.css":
|
||||||
|
":host { --example-gap: var(--ekklesia-spacing-sm); }\n.example { gap: var(--example-gap); }",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const artifact = await readFile(
|
||||||
|
(await buildModuleUi({ moduleDirectory: directory })).artifacts[0]?.outputFile ?? "",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(artifact).toContain("--example-gap");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compiles a display artifact's stylesheet the same way", async () => {
|
||||||
const directory = await createModule({
|
const directory = await createModule({
|
||||||
display: {
|
display: {
|
||||||
artifact: "./dist/display/index.js",
|
artifact: "./dist/display/index.js",
|
||||||
permission: "example.display.view",
|
permission: "example.display.view",
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
"src/display/index.tsx": 'import "./styles.css";\nexport default {};',
|
"src/display/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
"src/display/styles.css": ".display { position: fixed; }",
|
"src/display/styles.css": ".display { position: fixed; }",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
code: "MODULE_UI_BUILD_FAILED",
|
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
|
||||||
kind: "display",
|
|
||||||
});
|
expect(artifact).toContain(".display");
|
||||||
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
|
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from "./isolated-root.js";
|
||||||
export * from "./load-module-ui.js";
|
export * from "./load-module-ui.js";
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* The isolated root a host mounts a browser artifact into (ADR 0040).
|
||||||
|
*
|
||||||
|
* Every declared browser artifact — a module's primary UI, its display UI, and a contributed
|
||||||
|
* renderer — is mounted the same way, into its own Shadow DOM root the loader creates and owns:
|
||||||
|
*
|
||||||
|
* ```text
|
||||||
|
* host container ← the host's element; the loader never mutates it beyond this child
|
||||||
|
* └─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
|
||||||
|
* #shadow-root (open)
|
||||||
|
* ├─ <style data-ekklesia-module-ui-styles> base rules + the artifact's declared CSS
|
||||||
|
* └─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Shadow DOM is chosen because it is the one mechanism in a browser that actually **enforces** the
|
||||||
|
* rule rather than describing it: rules inside a shadow root do not apply outside it, and document
|
||||||
|
* rules do not apply inside it. A class-name convention, a build-time prefix, or a "please scope
|
||||||
|
* your selectors" instruction are all conventions a single mistake defeats, and the failure mode is
|
||||||
|
* a module silently restyling the Dashboard or another module's renderer.
|
||||||
|
*
|
||||||
|
* It is **not** a security boundary, and nothing here should be read as one. `mode: "open"` is
|
||||||
|
* deliberate: an installed module is trusted first-party code running in the host's own realm
|
||||||
|
* (ADR 0022, ADR 0024), it could reach `document` directly if it wanted to, and `mode: "closed"`
|
||||||
|
* would buy no protection while making the artifact's DOM unreachable to the host's own tests and
|
||||||
|
* diagnostics. This is a correctness boundary for *styles*, not a sandbox.
|
||||||
|
*
|
||||||
|
* Custom properties inherit through a shadow boundary, so the design tokens a host installed at its
|
||||||
|
* root (ADR 0039) are available inside every artifact with nothing passed and nothing imported.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MODULE_UI_ISOLATION_ATTRIBUTE = "data-ekklesia-module-ui";
|
||||||
|
export const MODULE_UI_ISOLATION_STYLE_ATTRIBUTE = "data-ekklesia-module-ui-styles";
|
||||||
|
export const MODULE_UI_ISOLATION_ROOT_ATTRIBUTE = "data-ekklesia-module-ui-root";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rules the host always installs, before any artifact CSS.
|
||||||
|
*
|
||||||
|
* Both wrappers are `display: contents`, so the two elements the loader adds contribute no boxes of
|
||||||
|
* their own and an artifact's own top-level elements lay out inside the host's container exactly as
|
||||||
|
* they did before isolation existed. They are declared as CSS rather than inline styles precisely so
|
||||||
|
* an artifact's own stylesheet — which the host appends *after* these — can override them when it
|
||||||
|
* genuinely needs a box to size.
|
||||||
|
*/
|
||||||
|
const BASE_STYLESHEET = `:host{display:contents}\n[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]{display:contents}`;
|
||||||
|
|
||||||
|
export interface IsolatedArtifactRoot {
|
||||||
|
/** The element handed to the artifact's `mount`. */
|
||||||
|
readonly mountTarget: Element;
|
||||||
|
/** The open shadow root, for a host's own tests and diagnostics. */
|
||||||
|
readonly shadowRoot: ShadowRoot;
|
||||||
|
/** Removes the whole isolated root — content and styles together. Idempotent. */
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one isolated root inside `container` and returns it.
|
||||||
|
*
|
||||||
|
* Nothing is attached to `container` until the shadow root, its stylesheet, and its mount target
|
||||||
|
* have all been created, so a host whose environment cannot provide Shadow DOM gets a thrown error
|
||||||
|
* and an untouched container rather than a half-built, unisolated mount.
|
||||||
|
*
|
||||||
|
* Each call creates a **fresh** root, and `dispose` removes it whole. That is what makes styles
|
||||||
|
* deterministic in both directions: a mount installs exactly one `<style>`, an unmount removes it
|
||||||
|
* with the element that owns it, and remounting cannot accumulate a second copy because there is
|
||||||
|
* nothing left to accumulate onto.
|
||||||
|
*/
|
||||||
|
export function createIsolatedArtifactRoot(
|
||||||
|
container: Element,
|
||||||
|
styles: string | undefined,
|
||||||
|
): IsolatedArtifactRoot {
|
||||||
|
const ownerDocument = container.ownerDocument;
|
||||||
|
if (!ownerDocument) {
|
||||||
|
throw new TypeError("A module UI container must belong to a document.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const shadowHost = ownerDocument.createElement("div");
|
||||||
|
shadowHost.setAttribute(MODULE_UI_ISOLATION_ATTRIBUTE, "");
|
||||||
|
if (typeof shadowHost.attachShadow !== "function") {
|
||||||
|
throw new TypeError("This environment does not support Shadow DOM.");
|
||||||
|
}
|
||||||
|
const shadowRoot = shadowHost.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
|
const style = ownerDocument.createElement("style");
|
||||||
|
style.setAttribute(MODULE_UI_ISOLATION_STYLE_ATTRIBUTE, "");
|
||||||
|
// The artifact's own CSS comes last, so it wins over the base rules above by ordinary cascade
|
||||||
|
// order rather than by anything the host has to special-case.
|
||||||
|
style.textContent = styles === undefined ? BASE_STYLESHEET : `${BASE_STYLESHEET}\n${styles}`;
|
||||||
|
shadowRoot.appendChild(style);
|
||||||
|
|
||||||
|
const mountTarget = ownerDocument.createElement("div");
|
||||||
|
mountTarget.setAttribute(MODULE_UI_ISOLATION_ROOT_ATTRIBUTE, "");
|
||||||
|
shadowRoot.appendChild(mountTarget);
|
||||||
|
|
||||||
|
container.appendChild(shadowHost);
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
return {
|
||||||
|
dispose: () => {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
disposed = true;
|
||||||
|
shadowHost.remove();
|
||||||
|
},
|
||||||
|
mountTarget,
|
||||||
|
shadowRoot,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type ModuleUiUnmount,
|
type ModuleUiUnmount,
|
||||||
parseModuleUiExport,
|
parseModuleUiExport,
|
||||||
} from "@ekklesia/module-ui-sdk";
|
} from "@ekklesia/module-ui-sdk";
|
||||||
|
import { createIsolatedArtifactRoot, type IsolatedArtifactRoot } from "./isolated-root.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a host obtains a module's UI artifact.
|
* How a host obtains a module's UI artifact.
|
||||||
@@ -45,7 +46,11 @@ const defaultImporter: ModuleUiImporter = (url) => import(url);
|
|||||||
|
|
||||||
export interface LoadModuleUiOptions {
|
export interface LoadModuleUiOptions {
|
||||||
readonly source: ModuleUiSource;
|
readonly source: ModuleUiSource;
|
||||||
/** The element the module renders into. The host owns it; the module owns its contents. */
|
/**
|
||||||
|
* The element the loader builds this artifact's isolated root inside. The host owns it, the
|
||||||
|
* module owns its own root's contents, and the loader adds and removes exactly one child of its
|
||||||
|
* own (ADR 0040). The artifact never receives this element.
|
||||||
|
*/
|
||||||
readonly container: Element;
|
readonly container: Element;
|
||||||
/** Supplied whole by the host. This package forwards it and never adds to it. */
|
/** Supplied whole by the host. This package forwards it and never adds to it. */
|
||||||
readonly context: ModuleUiHostContext;
|
readonly context: ModuleUiHostContext;
|
||||||
@@ -61,16 +66,28 @@ export interface MountedModuleUi {
|
|||||||
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
|
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
|
||||||
readonly mounted: boolean;
|
readonly mounted: boolean;
|
||||||
/**
|
/**
|
||||||
* Runs the module's cleanup, if it returned one. Idempotent: the first call cleans up, later
|
* This artifact's open shadow root. Exposed so a host's own tests and diagnostics can see what an
|
||||||
|
* artifact actually rendered; a host has no reason to write to it, and doing so would be reaching
|
||||||
|
* into content the module owns.
|
||||||
|
*/
|
||||||
|
readonly shadowRoot: ShadowRoot;
|
||||||
|
/**
|
||||||
|
* Runs the module's cleanup, if it returned one, then removes the isolated root the loader
|
||||||
|
* created — content and installed styles together. Idempotent: the first call cleans up, later
|
||||||
* calls do nothing. Safe when the module returned no cleanup function.
|
* calls do nothing. Safe when the module returned no cleanup function.
|
||||||
*/
|
*/
|
||||||
unmount(): Promise<void>;
|
unmount(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModuleUiHostErrorCode = "MODULE_UI_IMPORT_FAILED" | "MODULE_UI_MOUNT_FAILED";
|
export type ModuleUiHostErrorCode =
|
||||||
|
| "MODULE_UI_IMPORT_FAILED"
|
||||||
|
| "MODULE_UI_ISOLATION_UNAVAILABLE"
|
||||||
|
| "MODULE_UI_MOUNT_FAILED";
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
|
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
|
||||||
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
|
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
|
||||||
|
MODULE_UI_ISOLATION_UNAVAILABLE:
|
||||||
|
"A module UI artifact could not be given an isolated root in this environment.",
|
||||||
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
|
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,6 +120,12 @@ export class ModuleUiHostError extends Error {
|
|||||||
*
|
*
|
||||||
* Nothing is mounted until the artifact's declared identity matches the manifest identity the host
|
* Nothing is mounted until the artifact's declared identity matches the manifest identity the host
|
||||||
* passed in, so a stale or mismatched bundle fails before it can render or run `mount`.
|
* passed in, so a stale or mismatched bundle fails before it can render or run `mount`.
|
||||||
|
*
|
||||||
|
* Every artifact is mounted into its own isolated root the loader creates inside `container`
|
||||||
|
* (ADR 0040), and the artifact's declared `styles` are installed there. That is what keeps one
|
||||||
|
* module's stylesheet out of the Dashboard, out of another module, and out of another contributed
|
||||||
|
* renderer — and it applies identically to every role, so there is one mounting path rather than a
|
||||||
|
* styled one and an unstyled one.
|
||||||
*/
|
*/
|
||||||
export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> {
|
export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> {
|
||||||
const { container, context, source } = options;
|
const { container, context, source } = options;
|
||||||
@@ -124,16 +147,26 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
|
|||||||
version: source.version,
|
version: source.version,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Built before `mount` runs and torn down again if `mount` throws, so a failed artifact leaves
|
||||||
|
// neither DOM nor styles behind.
|
||||||
|
let isolated: IsolatedArtifactRoot;
|
||||||
|
try {
|
||||||
|
isolated = createIsolatedArtifactRoot(container, definition.styles);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ModuleUiHostError("MODULE_UI_ISOLATION_UNAVAILABLE", { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
let cleanup: ModuleUiUnmount | undefined;
|
let cleanup: ModuleUiUnmount | undefined;
|
||||||
try {
|
try {
|
||||||
const result = await definition.mount(container, context);
|
const result = await definition.mount(isolated.mountTarget, context);
|
||||||
if (typeof result === "function") {
|
if (typeof result === "function") {
|
||||||
cleanup = result;
|
cleanup = result;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// No cleanup is registered for a mount that never completed, so the returned handle can never
|
// No cleanup is registered for a mount that never completed, so the returned handle can never
|
||||||
// claim a failed module is mounted. The container is deliberately left untouched: the host owns
|
// claim a failed module is mounted. The host's own container is deliberately left as it was:
|
||||||
// it and decides what a failed mount should look like.
|
// the host owns it and decides what a failed mount should look like.
|
||||||
|
isolated.dispose();
|
||||||
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
|
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,14 +177,20 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
|
|||||||
get mounted() {
|
get mounted() {
|
||||||
return mounted;
|
return mounted;
|
||||||
},
|
},
|
||||||
|
shadowRoot: isolated.shadowRoot,
|
||||||
async unmount() {
|
async unmount() {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once
|
// Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once
|
||||||
// and a second call never re-runs it.
|
// and a second call never re-runs it. The isolated root is removed in a `finally`, so a
|
||||||
|
// module whose cleanup throws still has its styles taken out of the page.
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
try {
|
||||||
await cleanup?.();
|
await cleanup?.();
|
||||||
|
} finally {
|
||||||
|
isolated.dispose();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.css" {
|
||||||
|
const stylesheet: string;
|
||||||
|
export default stylesheet;
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||||
|
import styles from "./styles.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
|
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
|
||||||
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
|
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
|
||||||
*
|
*
|
||||||
* React is deliberately absent — the module template already proves the React path end to end, and
|
* React is deliberately absent — the module template already proves the React path end to end, and
|
||||||
* omitting it keeps this artifact small enough to serve as a `data:` URL.
|
* omitting it keeps this artifact small enough to serve as a `data:` URL. It does import a local
|
||||||
|
* stylesheet, so the built artifact also proves the CSS half of that loop: `.css` source -> compiled
|
||||||
|
* text inside the declared artifact -> installed in the isolated root at mount (ADR 0040).
|
||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "example",
|
id: "example",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
styles,
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
const response = await context.api.request("records");
|
const response = await context.api.request("records");
|
||||||
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
|
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* Compiled into the artifact by the canonical builder and installed into its isolated root by the
|
||||||
|
* loader (ADR 0040). It references a global token and declares none of its own. */
|
||||||
|
.artifact-label {
|
||||||
|
color: var(--ekklesia-text-primary);
|
||||||
|
}
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { ModuleUiContractError, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import {
|
||||||
|
MODULE_UI_ABI_VERSION,
|
||||||
|
ModuleUiContractError,
|
||||||
|
type ModuleUiHostContext,
|
||||||
|
} from "@ekklesia/module-ui-sdk";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { loadModuleUi, ModuleUiHostError, type ModuleUiImporter } from "../src/index.js";
|
import {
|
||||||
|
loadModuleUi,
|
||||||
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_STYLE_ATTRIBUTE,
|
||||||
|
ModuleUiHostError,
|
||||||
|
type ModuleUiImporter,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A browser-loadable URL for a module built from source text.
|
* A browser-loadable URL for a module built from source text.
|
||||||
@@ -15,10 +26,14 @@ function moduleUrl(source: string): string {
|
|||||||
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
|
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validArtifact = (options: { id?: string; version?: string; cleanup?: boolean } = {}) => `
|
const validArtifact = (
|
||||||
|
options: { id?: string; version?: string; cleanup?: boolean; styles?: string } = {},
|
||||||
|
) => `
|
||||||
export default {
|
export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(options.id ?? "announcements")},
|
id: ${JSON.stringify(options.id ?? "announcements")},
|
||||||
version: ${JSON.stringify(options.version ?? "1.2.0")},
|
version: ${JSON.stringify(options.version ?? "1.2.0")},
|
||||||
|
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
globalThis.__mounts ??= [];
|
globalThis.__mounts ??= [];
|
||||||
globalThis.__mounts.push({ moduleId: context.moduleId, container });
|
globalThis.__mounts.push({ moduleId: context.moduleId, container });
|
||||||
@@ -54,6 +69,20 @@ function container(): Element {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the artifact actually rendered, reached the way a host's own diagnostics would: through the
|
||||||
|
* open shadow root the loader created. The host's container itself holds only the loader's one
|
||||||
|
* wrapper element, which is exactly the isolation ADR 0040 introduced.
|
||||||
|
*/
|
||||||
|
function artifactRoot(element: Element): Element | null {
|
||||||
|
const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||||
|
return shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactText(element: Element): string {
|
||||||
|
return artifactRoot(element)?.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
const source = { moduleId: "announcements", version: "1.2.0" };
|
const source = { moduleId: "announcements", version: "1.2.0" };
|
||||||
|
|
||||||
describe("generic module UI host loader", () => {
|
describe("generic module UI host loader", () => {
|
||||||
@@ -70,10 +99,99 @@ describe("generic module UI host loader", () => {
|
|||||||
expect(mounted.moduleId).toBe("announcements");
|
expect(mounted.moduleId).toBe("announcements");
|
||||||
expect(mounted.version).toBe("1.2.0");
|
expect(mounted.version).toBe("1.2.0");
|
||||||
expect(mounted.mounted).toBe(true);
|
expect(mounted.mounted).toBe(true);
|
||||||
expect(element.textContent).toBe("mounted:announcements");
|
expect(artifactText(element)).toBe("mounted:announcements");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the host's own container and context straight through", async () => {
|
it("mounts the artifact into an isolated root rather than the host's own container", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The host's container gained exactly one child: the loader's shadow host.
|
||||||
|
expect(element.children).toHaveLength(1);
|
||||||
|
const shadowHost = element.firstElementChild as Element;
|
||||||
|
expect(shadowHost.hasAttribute(MODULE_UI_ISOLATION_ATTRIBUTE)).toBe(true);
|
||||||
|
expect(shadowHost.shadowRoot).toBe(mounted.shadowRoot);
|
||||||
|
// Open, deliberately: this is a correctness boundary for styles, not a sandbox — an installed
|
||||||
|
// module is trusted code in the host's own realm and could reach `document` regardless.
|
||||||
|
expect(mounted.shadowRoot.mode).toBe("open");
|
||||||
|
// The artifact's DOM is not reachable from the host's own document tree.
|
||||||
|
expect(element.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs a declared stylesheet inside the isolated root, exactly once", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact({ styles: ".posts{color:red}" })) },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = mounted.shadowRoot.querySelectorAll(
|
||||||
|
`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`,
|
||||||
|
);
|
||||||
|
expect(styles).toHaveLength(1);
|
||||||
|
expect(styles[0]?.textContent).toContain(".posts{color:red}");
|
||||||
|
// The host document never receives the artifact's rules.
|
||||||
|
expect(document.head.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs the host's base rules before the artifact's own, so an artifact can override them", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact({ styles: ":host{display:block}" })) },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stylesheet =
|
||||||
|
mounted.shadowRoot.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)
|
||||||
|
?.textContent ?? "";
|
||||||
|
expect(stylesheet.indexOf(":host{display:contents}")).toBeLessThan(
|
||||||
|
stylesheet.indexOf(":host{display:block}"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still mounts an artifact that declares no stylesheet", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(artifactText(element)).toBe("mounted:announcements");
|
||||||
|
expect(
|
||||||
|
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never accumulates styles across repeated mounts of the same container", async () => {
|
||||||
|
const element = container();
|
||||||
|
const url = moduleUrl(validArtifact({ styles: ".posts{color:red}" }));
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
|
||||||
|
).toHaveLength(1);
|
||||||
|
await mounted.unmount();
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the isolated mount target and the host's context straight through", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const requested: string[] = [];
|
const requested: string[] = [];
|
||||||
const context = hostContext(requested);
|
const context = hostContext(requested);
|
||||||
@@ -86,7 +204,9 @@ describe("generic module UI host loader", () => {
|
|||||||
|
|
||||||
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
|
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
|
||||||
.__mounts;
|
.__mounts;
|
||||||
expect(mounts?.at(-1)?.container).toBe(element);
|
// The artifact receives the loader's isolated root, never the host's own element.
|
||||||
|
expect(mounts?.at(-1)?.container).toBe(artifactRoot(element));
|
||||||
|
expect(mounts?.at(-1)?.container).not.toBe(element);
|
||||||
expect(mounts?.at(-1)?.moduleId).toBe("announcements");
|
expect(mounts?.at(-1)?.moduleId).toBe("announcements");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,7 +224,7 @@ describe("generic module UI host loader", () => {
|
|||||||
expect(requested.join(" ")).not.toContain("/api/v1/modules");
|
expect(requested.join(" ")).not.toContain("/api/v1/modules");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs the module's cleanup on unmount without touching the host container", async () => {
|
it("runs the module's cleanup and removes the isolated root without touching the host container", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const parent = element.parentElement;
|
const parent = element.parentElement;
|
||||||
|
|
||||||
@@ -116,7 +236,8 @@ describe("generic module UI host loader", () => {
|
|||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
|
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
expect(element.textContent).toBe("");
|
// The artifact's root, and with it its styles, are gone.
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
// The host owns the container; unmounting a module never removes it.
|
// The host owns the container; unmounting a module never removes it.
|
||||||
expect(element.parentElement).toBe(parent);
|
expect(element.parentElement).toBe(parent);
|
||||||
expect(document.body.contains(element)).toBe(true);
|
expect(document.body.contains(element)).toBe(true);
|
||||||
@@ -151,13 +272,15 @@ describe("generic module UI host loader", () => {
|
|||||||
|
|
||||||
await expect(mounted.unmount()).resolves.toBeUndefined();
|
await expect(mounted.unmount()).resolves.toBeUndefined();
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
|
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const artifact = `
|
const artifact = `
|
||||||
export default {
|
export default {
|
||||||
id: "announcements", version: "1.2.0",
|
abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
|
||||||
|
styles: ".x{color:red}",
|
||||||
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
|
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
|
||||||
};`;
|
};`;
|
||||||
|
|
||||||
@@ -169,6 +292,8 @@ describe("generic module UI host loader", () => {
|
|||||||
|
|
||||||
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
|
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
|
// A module that fails to clean up must not leave its stylesheet installed in the page.
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
await expect(mounted.unmount()).resolves.toBeUndefined();
|
await expect(mounted.unmount()).resolves.toBeUndefined();
|
||||||
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
|
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -189,16 +314,17 @@ describe("generic module UI host loader", () => {
|
|||||||
context: hostContext([]),
|
context: hostContext([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(first.textContent).toBe("mounted:announcements");
|
expect(artifactText(first)).toBe("mounted:announcements");
|
||||||
expect(second.textContent).toBe("mounted:announcements");
|
expect(artifactText(second)).toBe("mounted:announcements");
|
||||||
|
expect(a.shadowRoot).not.toBe(b.shadowRoot);
|
||||||
|
|
||||||
await a.unmount();
|
await a.unmount();
|
||||||
|
|
||||||
// Each handle owns its own cleanup state; unmounting one leaves the other alone.
|
// Each handle owns its own cleanup state; unmounting one leaves the other alone.
|
||||||
expect(a.mounted).toBe(false);
|
expect(a.mounted).toBe(false);
|
||||||
expect(b.mounted).toBe(true);
|
expect(b.mounted).toBe(true);
|
||||||
expect(first.textContent).toBe("");
|
expect(first.children).toHaveLength(0);
|
||||||
expect(second.textContent).toBe("mounted:announcements");
|
expect(artifactText(second)).toBe("mounted:announcements");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("rejects an artifact before mounting it", () => {
|
describe("rejects an artifact before mounting it", () => {
|
||||||
@@ -209,7 +335,7 @@ describe("generic module UI host loader", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||||
expect(element.textContent).toBe("");
|
expect(element.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("when the version does not match the manifest", async () => {
|
it("when the version does not match the manifest", async () => {
|
||||||
@@ -219,7 +345,19 @@ describe("generic module UI host loader", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||||
expect(element.textContent).toBe("");
|
expect(element.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when it was built against the previous mount ABI", async () => {
|
||||||
|
const element = container();
|
||||||
|
const url = moduleUrl(
|
||||||
|
'export default { id: "announcements", version: "1.2.0", mount() {} };',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_UI_ABI_UNSUPPORTED" });
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("when there is no default export", async () => {
|
it("when there is no default export", async () => {
|
||||||
@@ -233,7 +371,9 @@ describe("generic module UI host loader", () => {
|
|||||||
|
|
||||||
it("when the default export is not a module UI definition", async () => {
|
it("when the default export is not a module UI definition", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const url = moduleUrl('export default { id: "announcements", version: "1.2.0" };');
|
const url = moduleUrl(
|
||||||
|
`export default { abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0" };`,
|
||||||
|
);
|
||||||
|
|
||||||
const failure = loadModuleUi({
|
const failure = loadModuleUi({
|
||||||
source: { ...source, url },
|
source: { ...source, url },
|
||||||
@@ -263,11 +403,12 @@ describe("generic module UI host loader", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a throwing mount as a mount failure and registers no cleanup", async () => {
|
it("reports a throwing mount as a mount failure, registers no cleanup, and removes the root", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const url = moduleUrl(`
|
const url = moduleUrl(`
|
||||||
export default {
|
export default {
|
||||||
id: "announcements", version: "1.2.0",
|
abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
|
||||||
|
styles: ".x{color:red}",
|
||||||
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
|
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
|
||||||
};`);
|
};`);
|
||||||
|
|
||||||
@@ -284,9 +425,38 @@ describe("generic module UI host loader", () => {
|
|||||||
expect((error as ModuleUiHostError).message).not.toContain("exploded");
|
expect((error as ModuleUiHostError).message).not.toContain("exploded");
|
||||||
expect((error as { cause?: Error }).cause?.message).toBe("mount exploded");
|
expect((error as { cause?: Error }).cause?.message).toBe("mount exploded");
|
||||||
}
|
}
|
||||||
// No handle exists, so nothing can claim the module is mounted. The container is left exactly
|
// No handle exists, so nothing can claim the module is mounted — and a failed mount leaves
|
||||||
// as the module left it: the host owns it and decides what a failed mount looks like.
|
// neither partial DOM nor an installed stylesheet behind in the host's container.
|
||||||
expect(element.textContent).toBe("partial");
|
expect(element.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an environment with no Shadow DOM as an isolation failure, never as an unisolated mount", async () => {
|
||||||
|
const element = container();
|
||||||
|
const detached = {
|
||||||
|
ownerDocument: document,
|
||||||
|
appendChild: element.appendChild.bind(element),
|
||||||
|
} as unknown as Element;
|
||||||
|
// A container whose created element cannot take a shadow root: the loader must refuse rather
|
||||||
|
// than fall back to mounting the artifact — and its styles — straight into the host's page.
|
||||||
|
const noShadow = new Proxy(detached, {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
if (property === "ownerDocument") {
|
||||||
|
return {
|
||||||
|
createElement: () => ({ setAttribute: () => undefined }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Reflect.get(target, property, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||||
|
container: noShadow,
|
||||||
|
context: hostContext([]),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_UI_ISOLATION_UNAVAILABLE" });
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
|
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
|
||||||
@@ -294,7 +464,14 @@ describe("generic module UI host loader", () => {
|
|||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
const importModule: ModuleUiImporter = async (url) => {
|
const importModule: ModuleUiImporter = async (url) => {
|
||||||
seen.push(url);
|
seen.push(url);
|
||||||
return { default: { id: "announcements", version: "1.2.0", mount: () => undefined } };
|
return {
|
||||||
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
|
id: "announcements",
|
||||||
|
version: "1.2.0",
|
||||||
|
mount: () => undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mounted = await loadModuleUi({
|
const mounted = await loadModuleUi({
|
||||||
|
|||||||
@@ -5,7 +5,20 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import { loadModuleUi } from "../src/index.js";
|
import {
|
||||||
|
loadModuleUi,
|
||||||
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
/** What the artifact rendered, reached through the open shadow root the loader created (ADR 0040). */
|
||||||
|
function artifactText(container: Element): string {
|
||||||
|
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const fixtureDirectory = path.join(
|
const fixtureDirectory = path.join(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
@@ -80,12 +93,32 @@ describe("loading a real compiled artifact", () => {
|
|||||||
context: ekklesiaContext(requested),
|
context: ekklesiaContext(requested),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toBe("example:200:user-123");
|
expect(artifactText(container)).toBe("example:200:user-123");
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(container.children).toHaveLength(0);
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("compiles the fixture's stylesheet into the artifact and installs it in the isolated root", async () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
|
||||||
|
// No sidecar: the whole stylesheet travels inside the one declared JavaScript artifact.
|
||||||
|
expect(artifactSource).toContain(".artifact-label");
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
|
||||||
|
container,
|
||||||
|
context: ekklesiaContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stylesheet = mounted.shadowRoot.querySelector("style")?.textContent ?? "";
|
||||||
|
expect(stylesheet).toContain(".artifact-label");
|
||||||
|
expect(stylesheet).toContain("var(--ekklesia-text-primary)");
|
||||||
|
expect(document.head.textContent ?? "").not.toContain(".artifact-label");
|
||||||
|
|
||||||
|
await mounted.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it("still rejects the real artifact when the host expects a different version", async () => {
|
it("still rejects the real artifact when the host expects a different version", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
loadModuleUi({
|
loadModuleUi({
|
||||||
@@ -118,8 +151,8 @@ describe("loading a real compiled artifact", () => {
|
|||||||
// the module asked for "records" both times and resolved nothing itself.
|
// the module asked for "records" both times and resolved nothing itself.
|
||||||
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
|
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
|
||||||
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
|
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
|
||||||
expect(ekklesiaContainer.textContent).toBe("example:200:user-123");
|
expect(artifactText(ekklesiaContainer)).toBe("example:200:user-123");
|
||||||
expect(standaloneContainer.textContent).toBe("example:200:guest");
|
expect(artifactText(standaloneContainer)).toBe("example:200:guest");
|
||||||
|
|
||||||
await inEkklesia.unmount();
|
await inEkklesia.unmount();
|
||||||
await inStandalone.unmount();
|
await inStandalone.unmount();
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { type ModuleUiDefinition, parseModuleUiDefinition } from "./module-ui.js";
|
import {
|
||||||
|
MODULE_UI_ABI_VERSION,
|
||||||
|
type ModuleUiDefinition,
|
||||||
|
type ModuleUiDefinitionInput,
|
||||||
|
parseModuleUiDefinition,
|
||||||
|
} from "./module-ui.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The module author's entry point, mirroring `defineModuleRuntime` (ADR 0006): validate at authoring
|
* The module author's entry point, mirroring `defineModuleRuntime` (ADR 0006): validate at authoring
|
||||||
* time, freeze, and preserve the literal type. A malformed definition fails where it was written
|
* time, freeze, and stamp the browser mount ABI this artifact was built against. A malformed
|
||||||
* rather than in a host, at mount time, in a browser.
|
* definition fails where it was written rather than in a host, at mount time, in a browser.
|
||||||
|
*
|
||||||
|
* The ABI marker is added here rather than written by the author, so it can never drift from the SDK
|
||||||
|
* the artifact actually compiled against — which is the whole point of having one (ADR 0040).
|
||||||
*/
|
*/
|
||||||
export function defineModuleUi<const Definition extends ModuleUiDefinition>(
|
export function defineModuleUi(definition: ModuleUiDefinitionInput): ModuleUiDefinition {
|
||||||
definition: Definition,
|
return parseModuleUiDefinition({ ...definition, abi: MODULE_UI_ABI_VERSION });
|
||||||
): Readonly<Definition> {
|
|
||||||
return parseModuleUiDefinition(definition) as Readonly<Definition>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,38 @@ import type { ModuleUiHostContext } from "./host-context.js";
|
|||||||
|
|
||||||
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version of the browser mount ABI this package defines, and the only one a host accepts.
|
||||||
|
*
|
||||||
|
* Version 1 was `{ id, version, mount }` mounted straight into the host's own container. Version 2
|
||||||
|
* adds a declared `styles` stylesheet and makes every artifact mount inside an **isolated root** the
|
||||||
|
* host creates (ADR 0040). That is a behavioural change an artifact can observe — its DOM is no
|
||||||
|
* longer a direct child of the host's element — so it is versioned explicitly rather than
|
||||||
|
* introduced silently.
|
||||||
|
*
|
||||||
|
* There is deliberately no dual acceptance, exactly as there is none for a manifest version
|
||||||
|
* (ADR 0006, ADR 0014, ADR 0021, ADR 0033): an artifact built against version 1 fails to mount with
|
||||||
|
* `MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics. Migration is to
|
||||||
|
* rebuild the module, which is one `pnpm build` — the canonical builder owns every option, and a
|
||||||
|
* module that declares no stylesheet needs no source change at all.
|
||||||
|
*
|
||||||
|
* `defineModuleUi` stamps this for the author. A module never writes it.
|
||||||
|
*/
|
||||||
|
export const MODULE_UI_ABI_VERSION = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Released by a host when the module UI should be torn down. A module that acquires anything during
|
* Released by a host when the module UI should be torn down. A module that acquires anything during
|
||||||
* `mount` — a framework root, a subscription, a timer, an event listener — must return one of these
|
* `mount` — a framework root, a subscription, a timer, an event listener — must return one of these
|
||||||
* and release that work here, exactly as a backend module releases resources in its `stop` hook
|
* and release that work here, exactly as a backend module releases resources in its `stop` hook
|
||||||
* (ADR 0006). Returning nothing declares that the mount acquired nothing to release.
|
* (ADR 0006). Returning nothing declares that the mount acquired nothing to release.
|
||||||
|
*
|
||||||
|
* A module never has to remove its declared `styles`: the host installed them into the isolated root
|
||||||
|
* it owns, and it removes that whole root after this runs.
|
||||||
*/
|
*/
|
||||||
export type ModuleUiUnmount = () => Awaitable<void>;
|
export type ModuleUiUnmount = () => Awaitable<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
|
* What a module author passes to {@link defineModuleUi}.
|
||||||
* default-exports (ADR 0021, ADR 0022, ADR 0033).
|
|
||||||
*
|
*
|
||||||
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
|
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
|
||||||
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
|
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
|
||||||
@@ -23,19 +44,38 @@ export type ModuleUiUnmount = () => Awaitable<void>;
|
|||||||
* write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function,
|
* write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function,
|
||||||
* so a module's internal framework choice, and a host's, can each change without renegotiating the
|
* so a module's internal framework choice, and a host's, can each change without renegotiating the
|
||||||
* platform boundary.
|
* platform boundary.
|
||||||
|
*
|
||||||
|
* `styles` is the artifact's own compiled CSS **text**, not a URL, a filename, or a sidecar
|
||||||
|
* (ADR 0040). A module imports its local `.css` files and the canonical builder compiles them into
|
||||||
|
* this string, so the declared JavaScript artifact stays exactly as self-contained as it was before
|
||||||
|
* stylesheets existed: there is no second file to publish, nothing for a host to fetch, and no way
|
||||||
|
* for a stylesheet to become an asset nothing serves. The host installs it into the artifact's
|
||||||
|
* isolated root at mount and removes it at unmount.
|
||||||
*/
|
*/
|
||||||
export interface ModuleUiDefinition {
|
export interface ModuleUiDefinitionInput {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
|
readonly styles?: string;
|
||||||
readonly mount: (
|
readonly mount: (
|
||||||
container: Element,
|
container: Element,
|
||||||
context: ModuleUiHostContext,
|
context: ModuleUiHostContext,
|
||||||
) => Awaitable<ModuleUiUnmount | void>;
|
) => Awaitable<ModuleUiUnmount | void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
|
||||||
|
* default-exports (ADR 0021, ADR 0022, ADR 0033, ADR 0040): the author's declaration plus the ABI
|
||||||
|
* marker `defineModuleUi` stamped on it.
|
||||||
|
*/
|
||||||
|
export interface ModuleUiDefinition extends ModuleUiDefinitionInput {
|
||||||
|
readonly abi: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ModuleUiContractErrorCode =
|
export type ModuleUiContractErrorCode =
|
||||||
| "MODULE_UI_EXPORT_MISSING"
|
| "MODULE_UI_EXPORT_MISSING"
|
||||||
| "MODULE_UI_DEFINITION_INVALID"
|
| "MODULE_UI_DEFINITION_INVALID"
|
||||||
|
| "MODULE_UI_ABI_UNSUPPORTED"
|
||||||
|
| "MODULE_UI_STYLES_INVALID"
|
||||||
| "MODULE_UI_IDENTITY_MISMATCH";
|
| "MODULE_UI_IDENTITY_MISMATCH";
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({
|
const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({
|
||||||
@@ -43,6 +83,10 @@ const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Objec
|
|||||||
"A module UI entrypoint must have a default export produced by defineModuleUi.",
|
"A module UI entrypoint must have a default export produced by defineModuleUi.",
|
||||||
MODULE_UI_DEFINITION_INVALID:
|
MODULE_UI_DEFINITION_INVALID:
|
||||||
"A module UI definition must declare a non-empty id and version and a mount function, and nothing else.",
|
"A module UI definition must declare a non-empty id and version and a mount function, and nothing else.",
|
||||||
|
MODULE_UI_ABI_UNSUPPORTED:
|
||||||
|
"This module UI artifact was built for a different browser mount ABI. Rebuild the module against the current module UI SDK.",
|
||||||
|
MODULE_UI_STYLES_INVALID:
|
||||||
|
"A module UI definition's styles must be the artifact's compiled CSS text, as a non-empty string.",
|
||||||
MODULE_UI_IDENTITY_MISMATCH:
|
MODULE_UI_IDENTITY_MISMATCH:
|
||||||
"The module UI artifact's id and version do not match the module it was loaded for.",
|
"The module UI artifact's id and version do not match the module it was loaded for.",
|
||||||
});
|
});
|
||||||
@@ -64,7 +108,7 @@ export class ModuleUiContractError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const definitionKeys = new Set(["id", "mount", "version"]);
|
const definitionKeys = new Set(["abi", "id", "mount", "styles", "version"]);
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
@@ -80,12 +124,15 @@ function isNonEmptyString(value: unknown): value is string {
|
|||||||
* grammar — there is exactly one place each of those is defined, and it is not here.
|
* grammar — there is exactly one place each of those is defined, and it is not here.
|
||||||
*/
|
*/
|
||||||
export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition {
|
export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value) || value.abi !== MODULE_UI_ABI_VERSION) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
|
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
isNonEmptyString(value.id) &&
|
isNonEmptyString(value.id) &&
|
||||||
isNonEmptyString(value.version) &&
|
isNonEmptyString(value.version) &&
|
||||||
@@ -93,13 +140,31 @@ export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinitio
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an artifact's default export.
|
||||||
|
*
|
||||||
|
* The ABI marker is checked **first and on its own**, so an artifact built against the previous
|
||||||
|
* mount ABI is reported as exactly that rather than as a malformed definition — the difference
|
||||||
|
* between "rebuild your module" and a developer hunting for a typo.
|
||||||
|
*/
|
||||||
export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition {
|
export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
|
||||||
|
}
|
||||||
|
if (value.abi !== MODULE_UI_ABI_VERSION) {
|
||||||
|
throw new ModuleUiContractError("MODULE_UI_ABI_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
|
||||||
|
throw new ModuleUiContractError("MODULE_UI_STYLES_INVALID");
|
||||||
|
}
|
||||||
if (!isModuleUiDefinition(value)) {
|
if (!isModuleUiDefinition(value)) {
|
||||||
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
|
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
id: value.id,
|
id: value.id,
|
||||||
mount: value.mount,
|
mount: value.mount,
|
||||||
|
...(value.styles === undefined ? {} : { styles: value.styles }),
|
||||||
version: value.version,
|
version: value.version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,32 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
defineModuleUi,
|
defineModuleUi,
|
||||||
isModuleUiDefinition,
|
isModuleUiDefinition,
|
||||||
|
MODULE_UI_ABI_VERSION,
|
||||||
ModuleUiContractError,
|
ModuleUiContractError,
|
||||||
type ModuleUiApiClient,
|
type ModuleUiApiClient,
|
||||||
type ModuleUiDefinition,
|
type ModuleUiDefinition,
|
||||||
|
type ModuleUiDefinitionInput,
|
||||||
type ModuleUiHostContext,
|
type ModuleUiHostContext,
|
||||||
type ModuleUiRealtimeClient,
|
type ModuleUiRealtimeClient,
|
||||||
parseModuleUiDefinition,
|
parseModuleUiDefinition,
|
||||||
parseModuleUiExport,
|
parseModuleUiExport,
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
|
|
||||||
function validDefinition(): ModuleUiDefinition {
|
function definitionInput(): ModuleUiDefinitionInput {
|
||||||
return { id: "announcements", version: "0.1.0", mount: (): void => undefined };
|
return { id: "announcements", version: "0.1.0", mount: (): void => undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a compiled artifact actually default-exports: the author's declaration plus the ABI marker
|
||||||
|
* `defineModuleUi` stamps on it (ADR 0040). */
|
||||||
|
function validDefinition(): ModuleUiDefinition {
|
||||||
|
return { abi: MODULE_UI_ABI_VERSION, ...definitionInput() };
|
||||||
|
}
|
||||||
|
|
||||||
/** The contract treats the container as an opaque host-supplied mount target, so a plain object
|
/** The contract treats the container as an opaque host-supplied mount target, so a plain object
|
||||||
* with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the
|
* with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the
|
||||||
* contract, and this package deliberately does not take a DOM dependency to obtain one. */
|
* contract, and this package deliberately does not take a DOM dependency to obtain one. */
|
||||||
const noop = (): void => undefined;
|
const noop = (): void => undefined;
|
||||||
|
const abi = MODULE_UI_ABI_VERSION;
|
||||||
|
|
||||||
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
|
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
|
||||||
subscribe: () => Object.freeze({ close: noop }),
|
subscribe: () => Object.freeze({ close: noop }),
|
||||||
@@ -29,23 +38,66 @@ function fakeContainer(): Element & { textContent: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("module UI definition contract", () => {
|
describe("module UI definition contract", () => {
|
||||||
it("accepts and freezes a valid definition", () => {
|
it("accepts, stamps the ABI on, and freezes a valid definition", () => {
|
||||||
const definition = defineModuleUi(validDefinition());
|
const definition = defineModuleUi(definitionInput());
|
||||||
|
|
||||||
expect(definition.id).toBe("announcements");
|
expect(definition.id).toBe("announcements");
|
||||||
expect(definition.version).toBe("0.1.0");
|
expect(definition.version).toBe("0.1.0");
|
||||||
|
// Stamped by the SDK the artifact compiled against, never written by the author, so it can
|
||||||
|
// never drift from the contract the artifact was actually built for.
|
||||||
|
expect(definition.abi).toBe(MODULE_UI_ABI_VERSION);
|
||||||
|
expect(definition.styles).toBeUndefined();
|
||||||
expect(Object.isFrozen(definition)).toBe(true);
|
expect(Object.isFrozen(definition)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries a declared stylesheet through as the artifact's own compiled CSS text", () => {
|
||||||
|
const definition = defineModuleUi({ ...definitionInput(), styles: ".a{color:red}" });
|
||||||
|
|
||||||
|
expect(definition.styles).toBe(".a{color:red}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an artifact built against a different mount ABI, with its own code", () => {
|
||||||
|
// The whole point of the marker: a version-1 artifact is reported as "rebuild your module",
|
||||||
|
// not as a malformed definition a developer would hunt for a typo in.
|
||||||
|
const versionOne = { id: "announcements", version: "0.1.0", mount: noop };
|
||||||
|
|
||||||
|
expect(isModuleUiDefinition(versionOne)).toBe(false);
|
||||||
|
try {
|
||||||
|
parseModuleUiDefinition(versionOne);
|
||||||
|
expect.unreachable("expected a contract error");
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_ABI_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
expect(() =>
|
||||||
|
parseModuleUiDefinition({ ...versionOne, abi: MODULE_UI_ABI_VERSION + 1 }),
|
||||||
|
).toThrowError(expect.objectContaining({ code: "MODULE_UI_ABI_UNSUPPORTED" }));
|
||||||
|
});
|
||||||
|
|
||||||
it.each<[string, unknown]>([
|
it.each<[string, unknown]>([
|
||||||
["a missing id", { version: "0.1.0", mount: noop }],
|
["a blank stylesheet", " "],
|
||||||
["a blank id", { id: " ", version: "0.1.0", mount: noop }],
|
["a non-string stylesheet", 7],
|
||||||
["a non-string id", { id: 7, version: "0.1.0", mount: noop }],
|
["an array of stylesheets", [".a{}"]],
|
||||||
["a missing version", { id: "announcements", mount: noop }],
|
])("rejects %s", (_label, styles) => {
|
||||||
["a blank version", { id: "announcements", version: "", mount: noop }],
|
const candidate = { ...validDefinition(), styles };
|
||||||
["a missing mount", { id: "announcements", version: "0.1.0" }],
|
|
||||||
["a non-function mount", { id: "announcements", version: "0.1.0", mount: "render" }],
|
expect(isModuleUiDefinition(candidate)).toBe(false);
|
||||||
["an unknown property", { id: "announcements", version: "0.1.0", mount: noop, routes: [] }],
|
expect(() => parseModuleUiDefinition(candidate)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "MODULE_UI_STYLES_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<[string, unknown]>([
|
||||||
|
["a missing id", { abi, version: "0.1.0", mount: noop }],
|
||||||
|
["a blank id", { abi, id: " ", version: "0.1.0", mount: noop }],
|
||||||
|
["a non-string id", { abi, id: 7, version: "0.1.0", mount: noop }],
|
||||||
|
["a missing version", { abi, id: "announcements", mount: noop }],
|
||||||
|
["a blank version", { abi, id: "announcements", version: "", mount: noop }],
|
||||||
|
["a missing mount", { abi, id: "announcements", version: "0.1.0" }],
|
||||||
|
["a non-function mount", { abi, id: "announcements", version: "0.1.0", mount: "render" }],
|
||||||
|
[
|
||||||
|
"an unknown property",
|
||||||
|
{ abi, id: "announcements", version: "0.1.0", mount: noop, routes: [] },
|
||||||
|
],
|
||||||
])("rejects a definition with %s", (_label, candidate) => {
|
])("rejects a definition with %s", (_label, candidate) => {
|
||||||
expect(isModuleUiDefinition(candidate)).toBe(false);
|
expect(isModuleUiDefinition(candidate)).toBe(false);
|
||||||
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
|
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
|
||||||
@@ -64,7 +116,7 @@ describe("module UI definition contract", () => {
|
|||||||
|
|
||||||
it("reports a malformed definition with a stable, sanitized code", () => {
|
it("reports a malformed definition with a stable, sanitized code", () => {
|
||||||
try {
|
try {
|
||||||
parseModuleUiDefinition({ id: "announcements" });
|
parseModuleUiDefinition({ abi, id: "announcements" });
|
||||||
expect.unreachable("expected a contract error");
|
expect.unreachable("expected a contract error");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(ModuleUiContractError);
|
expect(error).toBeInstanceOf(ModuleUiContractError);
|
||||||
|
|||||||
Generated
+12
@@ -58,6 +58,9 @@ importers:
|
|||||||
'@ekklesia/module-ui-host':
|
'@ekklesia/module-ui-host':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/module-ui-host
|
version: link:../../packages/module-ui-host
|
||||||
|
'@ekklesia/module-ui-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/module-ui-sdk
|
||||||
'@ekklesia/typescript-config':
|
'@ekklesia/typescript-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/typescript-config
|
version: link:../../packages/typescript-config
|
||||||
@@ -73,6 +76,9 @@ importers:
|
|||||||
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]([email protected]))([email protected])([email protected](@types/[email protected])([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])))
|
||||||
|
happy-dom:
|
||||||
|
specifier: 20.0.11
|
||||||
|
version: 20.0.11
|
||||||
pg:
|
pg:
|
||||||
specifier: 8.22.0
|
specifier: 8.22.0
|
||||||
version: 8.22.0
|
version: 8.22.0
|
||||||
@@ -364,12 +370,18 @@ importers:
|
|||||||
|
|
||||||
templates/module:
|
templates/module:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@ekklesia/design-tokens':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/design-tokens
|
||||||
'@ekklesia/module-sdk':
|
'@ekklesia/module-sdk':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/module-sdk
|
version: link:../../packages/module-sdk
|
||||||
'@ekklesia/module-ui-sdk':
|
'@ekklesia/module-ui-sdk':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/module-ui-sdk
|
version: link:../../packages/module-ui-sdk
|
||||||
|
'@ekklesia/ui':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/ui
|
||||||
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])
|
||||||
|
|||||||
@@ -49,12 +49,13 @@ export interface BoundaryResult {
|
|||||||
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
|
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
|
||||||
// Presentation formats a module may NOT contain. `.tsx` is deliberately absent: React is the
|
// Presentation formats a module may NOT contain. `.tsx` is deliberately absent: React is the
|
||||||
// supported module UI authoring model (ADR 0022), so a module's own `.tsx` is legitimate source.
|
// supported module UI authoring model (ADR 0022), so a module's own `.tsx` is legitimate source.
|
||||||
// `.jsx` stays rejected because this repository is strict-TypeScript throughout, and `.css` stays
|
// `.jsx` stays rejected because this repository is strict-TypeScript throughout. `.css` is handled
|
||||||
// rejected because module styling and the module UI build pipeline are not designed yet — allowing
|
// separately below rather than listed here: since ADR 0040 a module may author a plain stylesheet,
|
||||||
// a stylesheet would imply a bundling contract that does not exist.
|
// but only inside the browser-artifact source roots the canonical builder actually compiles. Every
|
||||||
|
// other stylesheet dialect stays rejected outright — adding one is a toolchain decision needing its
|
||||||
|
// own ADR, not a detail of a build pass.
|
||||||
const MODULE_UI_EXTENSIONS = new Set([
|
const MODULE_UI_EXTENSIONS = new Set([
|
||||||
".astro",
|
".astro",
|
||||||
".css",
|
|
||||||
".htm",
|
".htm",
|
||||||
".html",
|
".html",
|
||||||
".jsx",
|
".jsx",
|
||||||
@@ -161,6 +162,25 @@ const SHARED_PACKAGE_ROLES = new Set([
|
|||||||
"ui",
|
"ui",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The module-package-relative directories whose contents `@ekklesia/module-ui-build` compiles into a
|
||||||
|
* declared browser artifact (ADR 0023, ADR 0033, ADR 0040).
|
||||||
|
*
|
||||||
|
* A stylesheet is only meaningful inside one of these: anywhere else in a module it would be a file
|
||||||
|
* no build reads, no manifest declares, and no host ever loads — the undeclared-sidecar failure this
|
||||||
|
* repository has rejected since ADR 0023, just relocated. Restricting the location is also what
|
||||||
|
* keeps the relaxation honest: a module gains stylesheets for its browser artifacts, not the ability
|
||||||
|
* to scatter presentation files through its backend.
|
||||||
|
*/
|
||||||
|
const MODULE_BROWSER_SOURCE_ROOTS = ["src/ui/", "src/display/"];
|
||||||
|
|
||||||
|
/** Whether a repository-relative path inside a module package sits in one of those roots. Module
|
||||||
|
* packages live at `modules/<name>/…` and `templates/module/…`, both two segments deep. */
|
||||||
|
function isModuleBrowserSource(relativePath: string): boolean {
|
||||||
|
const withinPackage = relativePath.split("/").slice(2).join("/");
|
||||||
|
return MODULE_BROWSER_SOURCE_ROOTS.some((sourceRoot) => withinPackage.startsWith(sourceRoot));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePath(value: string): string {
|
function normalizePath(value: string): string {
|
||||||
return value.split(path.sep).join("/").replace(/^\.\//, "");
|
return value.split(path.sep).join("/").replace(/^\.\//, "");
|
||||||
}
|
}
|
||||||
@@ -856,10 +876,21 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
|||||||
for (const repositoryFile of allFiles) {
|
for (const repositoryFile of allFiles) {
|
||||||
const relativeFile = normalizePath(path.relative(root, repositoryFile));
|
const relativeFile = normalizePath(path.relative(root, repositoryFile));
|
||||||
const location = describeRepositoryPath(relativeFile);
|
const location = describeRepositoryPath(relativeFile);
|
||||||
if (
|
if (location.layer !== "module") {
|
||||||
location.layer === "module" &&
|
continue;
|
||||||
MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile).toLowerCase())
|
}
|
||||||
) {
|
const extension = path.extname(repositoryFile).toLowerCase();
|
||||||
|
if (extension === ".css") {
|
||||||
|
if (!isModuleBrowserSource(location.relativePath)) {
|
||||||
|
violations.push({
|
||||||
|
from: relativeFile,
|
||||||
|
message: `A module stylesheet must live inside a compiled browser source root (${MODULE_BROWSER_SOURCE_ROOTS.join(", ")}).`,
|
||||||
|
rule: "modules-do-not-own-ui",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (MODULE_UI_EXTENSIONS.has(extension)) {
|
||||||
violations.push({
|
violations.push({
|
||||||
from: relativeFile,
|
from: relativeFile,
|
||||||
message: "UI source files belong in a surface, not a module.",
|
message: "UI source files belong in a surface, not a module.",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import { act, Component, type ReactElement, type ReactNode } from "react";
|
import { act, Component, type ReactElement, type ReactNode } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
HostEventSourceFactory,
|
HostEventSourceFactory,
|
||||||
HostEventSourceOptions,
|
HostEventSourceOptions,
|
||||||
} from "../src/host-protocol.js";
|
} from "../src/host-protocol.js";
|
||||||
|
import { deepQuery, isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
@@ -154,6 +155,7 @@ function artifact(
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
default: {
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
id,
|
id,
|
||||||
mount(element: Element, context: ModuleUiHostContext) {
|
mount(element: Element, context: ModuleUiHostContext) {
|
||||||
if (options.mount) {
|
if (options.mount) {
|
||||||
@@ -204,7 +206,7 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`]);
|
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`]);
|
||||||
expect(query("display-module-container")?.textContent).toBe("announcements:display");
|
expect(isolatedText(query("display-module-container"))).toBe("announcements:display");
|
||||||
expect(container.querySelector("header")).toBeNull();
|
expect(container.querySelector("header")).toBeNull();
|
||||||
expect(container.querySelector("nav")).toBeNull();
|
expect(container.querySelector("nav")).toBeNull();
|
||||||
expect(container.querySelector("aside")).toBeNull();
|
expect(container.querySelector("aside")).toBeNull();
|
||||||
@@ -239,7 +241,7 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
"realtime",
|
"realtime",
|
||||||
]);
|
]);
|
||||||
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||||
expect(query("display-module-container")?.textContent).toBe("permitted");
|
expect(isolatedText(query("display-module-container"))).toBe("permitted");
|
||||||
expect(
|
expect(
|
||||||
server.calls.some((call) => call.url.endsWith("/api/v1/modules/announcements/slides")),
|
server.calls.some((call) => call.url.endsWith("/api/v1/modules/announcements/slides")),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -255,6 +257,7 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
const requestedArtifacts: string[] = [];
|
const requestedArtifacts: string[] = [];
|
||||||
const source = `
|
const source = `
|
||||||
export default {
|
export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
version: "1.2.0",
|
version: "1.2.0",
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
@@ -303,7 +306,7 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
expect(requestedArtifacts).toEqual([
|
expect(requestedArtifacts).toEqual([
|
||||||
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
|
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
|
||||||
]);
|
]);
|
||||||
expect(container.querySelector('[data-testid="announcement-slide"]')?.getAttribute("src")).toBe(
|
expect(deepQuery(container, '[data-testid="announcement-slide"]')?.getAttribute("src")).toBe(
|
||||||
deliveryUrl,
|
deliveryUrl,
|
||||||
);
|
);
|
||||||
expect(container.innerHTML).not.toContain("socket.io");
|
expect(container.innerHTML).not.toContain("socket.io");
|
||||||
@@ -375,7 +378,10 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
|
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
|
||||||
expect(container.textContent).toContain("announcement management");
|
// The management artifact rendered inside its own isolated root, not the shell's own tree.
|
||||||
|
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
|
||||||
|
"announcement management",
|
||||||
|
);
|
||||||
expect(container.querySelector("nav")).not.toBeNull();
|
expect(container.querySelector("nav")).not.toBeNull();
|
||||||
expect(requestedArtifacts).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`]);
|
expect(requestedArtifacts).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`]);
|
||||||
|
|
||||||
@@ -401,7 +407,7 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`,
|
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`,
|
||||||
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
|
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
|
||||||
]);
|
]);
|
||||||
expect(container.querySelector('[data-testid="both-role-slide"]')?.getAttribute("src")).toBe(
|
expect(deepQuery(container, '[data-testid="both-role-slide"]')?.getAttribute("src")).toBe(
|
||||||
deliveryUrl,
|
deliveryUrl,
|
||||||
);
|
);
|
||||||
expect(container.querySelector("nav")).toBeNull();
|
expect(container.querySelector("nav")).toBeNull();
|
||||||
@@ -478,7 +484,7 @@ describe("display host — authentication and controlled failures", () => {
|
|||||||
await act(async () => query("display-retry")?.click());
|
await act(async () => query("display-retry")?.click());
|
||||||
|
|
||||||
expect(query("display-error")).toBeNull();
|
expect(query("display-error")).toBeNull();
|
||||||
expect(query("display-module-container")?.textContent).toBe("recovered");
|
expect(isolatedText(query("display-module-container"))).toBe("recovered");
|
||||||
expect(containers).toHaveLength(2);
|
expect(containers).toHaveLength(2);
|
||||||
expect(containers[0]).not.toBe(containers[1]);
|
expect(containers[0]).not.toBe(containers[1]);
|
||||||
expect(containers[0]?.isConnected).toBe(false);
|
expect(containers[0]?.isConnected).toBe(false);
|
||||||
@@ -508,7 +514,7 @@ describe("display host — authentication and controlled failures", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await render({ apiBaseUrl, fetch: server.fetch }, importer);
|
await render({ apiBaseUrl, fetch: server.fetch }, importer);
|
||||||
expect(query("display-module-container")?.textContent).toContain("/srv/modules");
|
expect(isolatedText(query("display-module-container"))).toContain("/srv/modules");
|
||||||
consoleError.mockClear();
|
consoleError.mockClear();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -525,7 +531,7 @@ describe("display host — authentication and controlled failures", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(query("display-error")).not.toBeNull();
|
expect(query("display-error")).not.toBeNull();
|
||||||
expect(query("display-module-container")?.textContent).toBe("");
|
expect(isolatedText(query("display-module-container"))).toBe("");
|
||||||
expect(container.textContent).toBe("This display could not be loaded.Retry");
|
expect(container.textContent).toBe("This display could not be loaded.Retry");
|
||||||
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
|
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
|
||||||
expect(importer).toHaveBeenCalledTimes(1);
|
expect(importer).toHaveBeenCalledTimes(1);
|
||||||
@@ -541,7 +547,7 @@ describe("display host — authentication and controlled failures", () => {
|
|||||||
|
|
||||||
expect(importer).toHaveBeenCalledTimes(2);
|
expect(importer).toHaveBeenCalledTimes(2);
|
||||||
expect(query("display-error")).toBeNull();
|
expect(query("display-error")).toBeNull();
|
||||||
expect(query("display-module-container")?.textContent).toContain("/srv/modules");
|
expect(isolatedText(query("display-module-container"))).toContain("/srv/modules");
|
||||||
expect(mountedInto[0]?.isConnected).toBe(false);
|
expect(mountedInto[0]?.isConnected).toBe(false);
|
||||||
expect(mountedInto[1]?.isConnected).toBe(true);
|
expect(mountedInto[1]?.isConnected).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -610,7 +616,7 @@ describe("display host — authentication and controlled failures", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
container.querySelector('[data-testid="display-media"]')?.dispatchEvent(
|
deepQuery(container, '[data-testid="display-media"]')?.dispatchEvent(
|
||||||
new ErrorEvent("error", {
|
new ErrorEvent("error", {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
@@ -620,7 +626,7 @@ describe("display host — authentication and controlled failures", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(query("display-error")).toBeNull();
|
expect(query("display-error")).toBeNull();
|
||||||
expect(query("display-media")).not.toBeNull();
|
expect(deepQuery(container, '[data-testid="display-media"]')).not.toBeNull();
|
||||||
expect(cleanup).not.toHaveBeenCalled();
|
expect(cleanup).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -686,7 +692,7 @@ describe("display host — independent instances and stale work", () => {
|
|||||||
|
|
||||||
expect(cleanups).toHaveLength(1);
|
expect(cleanups).toHaveLength(1);
|
||||||
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(1);
|
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(1);
|
||||||
expect(query("display-module-container")?.textContent).toBe("announcements:display");
|
expect(isolatedText(query("display-module-container"))).toBe("announcements:display");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("discards a late import into its detached container without disturbing the newer route", async () => {
|
it("discards a late import into its detached container without disturbing the newer route", async () => {
|
||||||
@@ -728,7 +734,7 @@ describe("display host — independent instances and stale work", () => {
|
|||||||
|
|
||||||
await render(config, importer);
|
await render(config, importer);
|
||||||
await render(config, importer, "calendar");
|
await render(config, importer, "calendar");
|
||||||
expect(query("display-module-container")?.textContent).toBe("calendar-live");
|
expect(isolatedText(query("display-module-container"))).toBe("calendar-live");
|
||||||
const liveContainer = query("display-module-container");
|
const liveContainer = query("display-module-container");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -737,8 +743,9 @@ describe("display host — independent instances and stale work", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(query("display-module-container")).toBe(liveContainer);
|
expect(query("display-module-container")).toBe(liveContainer);
|
||||||
expect(liveContainer?.textContent).toBe("calendar-live");
|
expect(isolatedText(liveContainer)).toBe("calendar-live");
|
||||||
const staleContainer = mountedInto.find((element) => element !== liveContainer);
|
const liveMountTarget = isolatedMountTarget(liveContainer);
|
||||||
|
const staleContainer = mountedInto.find((element) => element !== liveMountTarget);
|
||||||
expect(staleContainer?.isConnected).toBe(false);
|
expect(staleContainer?.isConnected).toBe(false);
|
||||||
expect(staleUnmounts).toEqual([staleContainer]);
|
expect(staleUnmounts).toEqual([staleContainer]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { HostApp } from "../src/browser/HostApp.js";
|
import { HostApp } from "../src/browser/HostApp.js";
|
||||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||||
|
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
@@ -99,6 +100,7 @@ interface ArtifactOptions {
|
|||||||
function artifact(id: string, version: string, options: ArtifactOptions = {}) {
|
function artifact(id: string, version: string, options: ArtifactOptions = {}) {
|
||||||
return {
|
return {
|
||||||
default: {
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
id,
|
id,
|
||||||
mount(element: Element, context: ModuleUiHostContext) {
|
mount(element: Element, context: ModuleUiHostContext) {
|
||||||
options.mountedInto?.push(element);
|
options.mountedInto?.push(element);
|
||||||
@@ -443,7 +445,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
|||||||
|
|
||||||
expect(query("host-login")).toBeNull();
|
expect(query("host-login")).toBeNull();
|
||||||
expect(query("host-logout")).not.toBeNull();
|
expect(query("host-logout")).not.toBeNull();
|
||||||
expect(query("module-container")?.textContent).toBe("example mounted");
|
expect(isolatedText(query("module-container"))).toBe("example mounted");
|
||||||
expect(importer).toHaveBeenCalledOnce();
|
expect(importer).toHaveBeenCalledOnce();
|
||||||
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
|
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
|
||||||
"/api/v1/principal",
|
"/api/v1/principal",
|
||||||
@@ -565,6 +567,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
|||||||
},
|
},
|
||||||
async () => ({
|
async () => ({
|
||||||
default: {
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
id: "example",
|
id: "example",
|
||||||
mount(element: Element, context: ModuleUiHostContext) {
|
mount(element: Element, context: ModuleUiHostContext) {
|
||||||
element.textContent = "example mounted";
|
element.textContent = "example mounted";
|
||||||
@@ -693,6 +696,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
default: {
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
id: "example",
|
id: "example",
|
||||||
mount(element: Element, context: ModuleUiHostContext) {
|
mount(element: Element, context: ModuleUiHostContext) {
|
||||||
capture.context = context;
|
capture.context = context;
|
||||||
@@ -770,7 +774,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(query("host-login")).toBeNull();
|
expect(query("host-login")).toBeNull();
|
||||||
expect(query("module-container")?.textContent).toBe("example mounted");
|
expect(isolatedText(query("module-container"))).toBe("example mounted");
|
||||||
expect(capture.unmountCalls).toBe(0);
|
expect(capture.unmountCalls).toBe(0);
|
||||||
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
|
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
|
||||||
});
|
});
|
||||||
@@ -790,7 +794,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(query("host-login")).toBeNull();
|
expect(query("host-login")).toBeNull();
|
||||||
expect(query("module-container")?.textContent).toBe("example mounted");
|
expect(isolatedText(query("module-container"))).toBe("example mounted");
|
||||||
expect(capture.unmountCalls).toBe(0);
|
expect(capture.unmountCalls).toBe(0);
|
||||||
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
|
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
|
||||||
});
|
});
|
||||||
@@ -920,7 +924,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
|||||||
`${apiBaseUrl}/api/v1/module-ui/video/0.1.0`,
|
`${apiBaseUrl}/api/v1/module-ui/video/0.1.0`,
|
||||||
`${apiBaseUrl}/api/v1/module-ui/calendar/0.1.0`,
|
`${apiBaseUrl}/api/v1/module-ui/calendar/0.1.0`,
|
||||||
]);
|
]);
|
||||||
expect(query("module-container")?.textContent).toBe("calendar mounted");
|
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mounts the identical artifact whichever group a module is presented in", async () => {
|
it("mounts the identical artifact whichever group a module is presented in", async () => {
|
||||||
@@ -972,10 +976,11 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
artifact("example", "0.1.0", { mountedInto }),
|
artifact("example", "0.1.0", { mountedInto }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(query("module-container")?.textContent).toBe("example mounted");
|
expect(isolatedText(query("module-container"))).toBe("example mounted");
|
||||||
expect(query("module-loading")).toBeNull();
|
expect(query("module-loading")).toBeNull();
|
||||||
// The module was handed the shell's container, never the page root or the shell's own layout.
|
// The module was handed the shell's container, never the page root or the shell's own layout.
|
||||||
expect(mountedInto[0]).toBe(query("module-container"));
|
// The artifact receives the loader's isolated root, never the shell's own element (ADR 0040).
|
||||||
|
expect(mountedInto[0]).toBe(isolatedMountTarget(query("module-container")));
|
||||||
expect(mountedInto[0]).not.toBe(container);
|
expect(mountedInto[0]).not.toBe(container);
|
||||||
expect(mountedInto[0]).not.toBe(document.body);
|
expect(mountedInto[0]).not.toBe(document.body);
|
||||||
});
|
});
|
||||||
@@ -1002,7 +1007,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(query("module-loading")).not.toBeNull();
|
expect(query("module-loading")).not.toBeNull();
|
||||||
expect(query("module-container")?.textContent).toBe("");
|
expect(isolatedText(query("module-container"))).toBe("");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
release?.();
|
release?.();
|
||||||
@@ -1010,7 +1015,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(query("module-loading")).toBeNull();
|
expect(query("module-loading")).toBeNull();
|
||||||
expect(query("module-container")?.textContent).toBe("example mounted");
|
expect(isolatedText(query("module-container"))).toBe("example mounted");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unmounts the previous module when the selection changes", async () => {
|
it("unmounts the previous module when the selection changes", async () => {
|
||||||
@@ -1019,6 +1024,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
const id = url.includes("/calendar/") ? "calendar" : "example";
|
const id = url.includes("/calendar/") ? "calendar" : "example";
|
||||||
return {
|
return {
|
||||||
default: {
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
id,
|
id,
|
||||||
mount(element: Element) {
|
mount(element: Element) {
|
||||||
element.textContent = `${id} mounted`;
|
element.textContent = `${id} mounted`;
|
||||||
@@ -1040,14 +1046,14 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
},
|
},
|
||||||
importModule,
|
importModule,
|
||||||
);
|
);
|
||||||
expect(query("module-container")?.textContent).toBe("example mounted");
|
expect(isolatedText(query("module-container"))).toBe("example mounted");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
(query("select-calendar") as HTMLButtonElement).click();
|
(query("select-calendar") as HTMLButtonElement).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(unmountedContainers).toHaveLength(1);
|
expect(unmountedContainers).toHaveLength(1);
|
||||||
expect(query("module-container")?.textContent).toBe("calendar mounted");
|
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a stale load from ever reaching the newer selection's container", async () => {
|
it("keeps a stale load from ever reaching the newer selection's container", async () => {
|
||||||
@@ -1065,6 +1071,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
default: {
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
id,
|
id,
|
||||||
mount(element: Element) {
|
mount(element: Element) {
|
||||||
mountedInto.push(element);
|
mountedInto.push(element);
|
||||||
@@ -1096,7 +1103,8 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
(query("select-calendar") as HTMLButtonElement).click();
|
(query("select-calendar") as HTMLButtonElement).click();
|
||||||
});
|
});
|
||||||
const liveContainer = query("module-container");
|
const liveContainer = query("module-container");
|
||||||
expect(liveContainer?.textContent).toBe("calendar mounted");
|
const liveMountTarget = isolatedMountTarget(liveContainer);
|
||||||
|
expect(isolatedText(liveContainer)).toBe("calendar mounted");
|
||||||
|
|
||||||
// Only now does the stale `example` import resolve.
|
// Only now does the stale `example` import resolve.
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -1104,10 +1112,10 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
await examplePending;
|
await examplePending;
|
||||||
});
|
});
|
||||||
|
|
||||||
// It mounted into its own, already-discarded container — never the live one — and was torn
|
// It mounted into its own, already-discarded isolated root — never the live one — and was torn
|
||||||
// down immediately, so the newer selection is untouched.
|
// down immediately, so the newer selection is untouched.
|
||||||
expect(liveContainer?.textContent).toBe("calendar mounted");
|
expect(isolatedText(liveContainer)).toBe("calendar mounted");
|
||||||
const staleContainer = mountedInto.find((element) => element !== liveContainer);
|
const staleContainer = mountedInto.find((element) => element !== liveMountTarget);
|
||||||
expect(staleContainer).toBeDefined();
|
expect(staleContainer).toBeDefined();
|
||||||
expect(staleContainer?.isConnected).toBe(false);
|
expect(staleContainer?.isConnected).toBe(false);
|
||||||
expect(unmountedContainers).toContain(staleContainer);
|
expect(unmountedContainers).toContain(staleContainer);
|
||||||
@@ -1140,7 +1148,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
|||||||
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "9.9.9"));
|
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "9.9.9"));
|
||||||
|
|
||||||
expect(query("module-error")).not.toBeNull();
|
expect(query("module-error")).not.toBeNull();
|
||||||
expect(query("module-container")?.textContent).toBe("");
|
expect(isolatedText(query("module-container"))).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders its own error when the module's mount throws", async () => {
|
it("renders its own error when the module's mount throws", async () => {
|
||||||
@@ -1174,7 +1182,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(query("module-error")).toBeNull();
|
expect(query("module-error")).toBeNull();
|
||||||
expect(query("module-container")?.textContent).toBe("calendar mounted");
|
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("survives a module whose cleanup throws, and never re-runs it", async () => {
|
it("survives a module whose cleanup throws, and never re-runs it", async () => {
|
||||||
@@ -1185,6 +1193,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
default: {
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
id: "example",
|
id: "example",
|
||||||
mount(element: Element) {
|
mount(element: Element) {
|
||||||
element.textContent = "example mounted";
|
element.textContent = "example mounted";
|
||||||
@@ -1212,7 +1221,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(cleanupCalls).toBe(1);
|
expect(cleanupCalls).toBe(1);
|
||||||
expect(query("module-container")?.textContent).toBe("calendar mounted");
|
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
|
||||||
|
|
||||||
// Switch back and away again: the throwing cleanup ran once per mount, never re-invoked for an
|
// Switch back and away again: the throwing cleanup ran once per mount, never re-invoked for an
|
||||||
// instance already torn down.
|
// instance already torn down.
|
||||||
@@ -1223,6 +1232,6 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
|||||||
(query("select-calendar") as HTMLButtonElement).click();
|
(query("select-calendar") as HTMLButtonElement).click();
|
||||||
});
|
});
|
||||||
expect(cleanupCalls).toBe(2);
|
expect(cleanupCalls).toBe(2);
|
||||||
expect(query("module-container")?.textContent).toBe("calendar mounted");
|
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Test helpers for reaching a mounted module artifact's DOM.
|
||||||
|
*
|
||||||
|
* Since ADR 0040 the generic loader mounts every declared browser artifact inside its own Shadow DOM
|
||||||
|
* root, so a module's elements are deliberately *not* in the host document's tree: `querySelector`
|
||||||
|
* on the shell's container cannot see them, and neither can a stray Dashboard stylesheet. These
|
||||||
|
* helpers walk into those open roots so a shell test can still assert on what a module rendered —
|
||||||
|
* which is exactly what a host's own diagnostics would do, and is why the roots are `open`.
|
||||||
|
*
|
||||||
|
* Deliberately test-only. Production shell code never reaches into an artifact's root; the module
|
||||||
|
* owns its contents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ISOLATION_HOST_SELECTOR = "[data-ekklesia-module-ui]";
|
||||||
|
|
||||||
|
function isolatedRoots(root: ParentNode): readonly ShadowRoot[] {
|
||||||
|
return [...root.querySelectorAll(ISOLATION_HOST_SELECTOR)]
|
||||||
|
.map((host) => host.shadowRoot)
|
||||||
|
.filter((shadowRoot): shadowRoot is ShadowRoot => shadowRoot !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first match anywhere in `root`, including inside every mounted artifact's isolated root. */
|
||||||
|
export function deepQuery(root: ParentNode, selector: string): Element | null {
|
||||||
|
const direct = root.querySelector(selector);
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
for (const shadowRoot of isolatedRoots(root)) {
|
||||||
|
const nested = deepQuery(shadowRoot, selector);
|
||||||
|
if (nested) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every match anywhere in `root`, host tree first, then each artifact's isolated root in order. */
|
||||||
|
export function deepQueryAll(root: ParentNode, selector: string): readonly Element[] {
|
||||||
|
return [
|
||||||
|
...root.querySelectorAll(selector),
|
||||||
|
...isolatedRoots(root).flatMap((shadowRoot) => [...deepQueryAll(shadowRoot, selector)]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text an artifact rendered, read from the exact element the loader hands its `mount` — so the
|
||||||
|
* stylesheet the loader installed alongside it is never mistaken for content. */
|
||||||
|
export function isolatedText(container: Element | null | undefined): string {
|
||||||
|
return isolatedMountTarget(container)?.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The element the loader passed to a mounted artifact's `mount`, for identity assertions. */
|
||||||
|
export function isolatedMountTarget(container: Element | null | undefined): Element | null {
|
||||||
|
const host = container?.querySelector(ISOLATION_HOST_SELECTOR);
|
||||||
|
return host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]") ?? null;
|
||||||
|
}
|
||||||
@@ -27,8 +27,10 @@
|
|||||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ekklesia/design-tokens": "workspace:*",
|
||||||
"@ekklesia/module-sdk": "workspace:*",
|
"@ekklesia/module-sdk": "workspace:*",
|
||||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
|
"@ekklesia/ui": "workspace:*",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import displayStyles from "./styles.css";
|
||||||
|
|
||||||
function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
|
function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
|
||||||
const mayView = props.context.permissions.has("example.display.view");
|
const mayView = props.context.permissions.has("example.display.view");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main className="example-display" data-testid="example-display">
|
||||||
data-testid="example-display"
|
<h1 className="example-display__headline">
|
||||||
style={{
|
|
||||||
alignItems: "center",
|
|
||||||
background: "#111827",
|
|
||||||
color: "#f9fafb",
|
|
||||||
display: "flex",
|
|
||||||
fontFamily: "system-ui, sans-serif",
|
|
||||||
height: "100%",
|
|
||||||
justifyContent: "center",
|
|
||||||
minHeight: "100vh",
|
|
||||||
padding: "2rem",
|
|
||||||
textAlign: "center",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{ margin: 0 }}>
|
|
||||||
{mayView
|
{mayView
|
||||||
? `Example full-screen display (${props.context.moduleId})`
|
? `Example full-screen display (${props.context.moduleId})`
|
||||||
: "Display unavailable"}
|
: "Display unavailable"}
|
||||||
@@ -33,10 +19,15 @@ function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
|
|||||||
/**
|
/**
|
||||||
* A display uses the same role-neutral ABI as the primary UI. Its separate manifest entrypoint —
|
* A display uses the same role-neutral ABI as the primary UI. Its separate manifest entrypoint —
|
||||||
* not a mode flag in `ModuleUiHostContext` — tells a host which artifact it is mounting.
|
* not a mode flag in `ModuleUiHostContext` — tells a host which artifact it is mounting.
|
||||||
|
*
|
||||||
|
* It declares its own stylesheet, isolated from the primary UI's exactly as it is from the
|
||||||
|
* Dashboard's: two artifacts of the same module share no styles unless they share a source file
|
||||||
|
* (ADR 0040).
|
||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "example",
|
id: "example",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
styles: displayStyles,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
root.render(<ExampleDisplay context={context} />);
|
root.render(<ExampleDisplay context={context} />);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* The display artifact's own stylesheet.
|
||||||
|
*
|
||||||
|
* A full-viewport display is a different product experience from the management UI, so it gets its
|
||||||
|
* own rules in its own isolated root — the two artifacts of this one module share no styles unless
|
||||||
|
* they share a source file (ADR 0040).
|
||||||
|
*
|
||||||
|
* `:host { display: block }` is a deliberate override of the platform's transparent default: a
|
||||||
|
* display needs a real box to fill the viewport, and the host installs its base rules before this
|
||||||
|
* stylesheet precisely so an artifact can override them by ordinary cascade order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-display {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--ekklesia-background-page);
|
||||||
|
color: var(--ekklesia-text-primary);
|
||||||
|
display: flex;
|
||||||
|
font-family: var(--ekklesia-typography-family-sans);
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: var(--ekklesia-spacing-2xl);
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-display__headline {
|
||||||
|
font-size: var(--ekklesia-typography-size-heading-1);
|
||||||
|
font-weight: var(--ekklesia-typography-weight-bold);
|
||||||
|
line-height: var(--ekklesia-typography-line-height-tight);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* What `@ekklesia/module-ui-build` turns a local stylesheet import into: that stylesheet's compiled
|
||||||
|
* CSS **text**, as a string (ADR 0040).
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import styles from "./styles.css";
|
||||||
|
* export default defineModuleUi({ id, version, styles, mount });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* There is no sidecar file and no URL — the text is compiled into the declared JavaScript artifact,
|
||||||
|
* and the host installs it into the artifact's isolated root at mount. Copy this file into your own
|
||||||
|
* module and include it in your UI TypeScript project.
|
||||||
|
*/
|
||||||
|
declare module "*.css" {
|
||||||
|
const stylesheet: string;
|
||||||
|
export default stylesheet;
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { EmptyState, LoadingState, Panel } from "@ekklesia/ui";
|
||||||
import { type ReactElement, useEffect, useState } from "react";
|
import { type ReactElement, useEffect, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads
|
* 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.
|
* everything it needs from the host context and hardcodes no URL, no origin, and no global.
|
||||||
|
*
|
||||||
|
* Presentation comes from the platform's shared elements and this module's own stylesheet
|
||||||
|
* (`./styles.css`, declared through `defineModuleUi({ styles })`), so it matches every other
|
||||||
|
* Ekklesia screen without copying a single colour value (ADR 0039, ADR 0040).
|
||||||
*/
|
*/
|
||||||
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
||||||
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
|
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
|
||||||
@@ -39,15 +44,25 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext
|
|||||||
}, [canRead, context.api]);
|
}, [canRead, context.api]);
|
||||||
|
|
||||||
if (!canRead) {
|
if (!canRead) {
|
||||||
return <p style={{ margin: 0 }}>You do not have access to example records.</p>;
|
return (
|
||||||
|
<EmptyState
|
||||||
|
description="Ask an administrator for the example.records.read permission."
|
||||||
|
title="No access to example records"
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ display: "grid", gap: "0.5rem" }}>
|
<Panel description="Everything this module knows about." title="Example module UI">
|
||||||
<h1 style={{ margin: 0 }}>Example module UI</h1>
|
{recordCount === undefined ? (
|
||||||
<p style={{ margin: 0 }} data-testid="record-count">
|
<LoadingState label="Loading records…" />
|
||||||
{recordCount === undefined ? "Loading records…" : `${recordCount} record(s)`}
|
) : (
|
||||||
|
<div className="example-summary">
|
||||||
|
<p className="example-summary__count" data-testid="record-count">
|
||||||
|
{`${recordCount} record(s)`}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { ExampleApp } from "./ExampleApp.js";
|
import { ExampleApp } from "./ExampleApp.js";
|
||||||
|
import ownStyles from "./styles.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The module's compiled UI entrypoint. `pnpm build` bundles this file, and everything it imports,
|
* The module's compiled UI entrypoint. `pnpm build` bundles this file, and everything it imports,
|
||||||
@@ -8,10 +10,17 @@ import { ExampleApp } from "./ExampleApp.js";
|
|||||||
*
|
*
|
||||||
* `id` and `version` must match the manifest: a host verifies both before mounting, so a stale
|
* `id` and `version` must match the manifest: a host verifies both before mounting, so a stale
|
||||||
* artifact is caught rather than rendered.
|
* artifact is caught rather than rendered.
|
||||||
|
*
|
||||||
|
* `styles` is this artifact's complete stylesheet: the shared elements' rules plus this module's own
|
||||||
|
* (ADR 0040). Both are compiled into the artifact as text and installed by the host into the
|
||||||
|
* artifact's isolated root at mount, so nothing here leaks into the Dashboard and nothing in the
|
||||||
|
* Dashboard reaches in. The design tokens both stylesheets reference are inherited from the host's
|
||||||
|
* root rather than bundled (ADR 0039).
|
||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "example",
|
id: "example",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
root.render(<ExampleApp context={context} />);
|
root.render(<ExampleApp context={context} />);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* This module's own stylesheet.
|
||||||
|
*
|
||||||
|
* `@ekklesia/module-ui-build` compiles it into the declared browser artifact as text, and the host
|
||||||
|
* installs it into this artifact's isolated root at mount (ADR 0040). There is no sidecar file to
|
||||||
|
* publish and no URL to fetch.
|
||||||
|
*
|
||||||
|
* Two rules make this safe to write freely:
|
||||||
|
*
|
||||||
|
* 1. These selectors apply **only** inside this artifact's own root. `.example-summary` here cannot
|
||||||
|
* reach the Dashboard, another module, or another contributed renderer — and their rules cannot
|
||||||
|
* reach in.
|
||||||
|
* 2. Reference Ekklesia's global theme variables; never declare one. A private alias under your own
|
||||||
|
* prefix, like `--example-*` below, is exactly the supported way to name a local value (ADR 0039).
|
||||||
|
* The build rejects a stylesheet that declares an `--ekklesia-*` property.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
--example-summary-gap: var(--ekklesia-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--example-summary-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-summary__count {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -207,6 +207,26 @@ describe("module UI build pipeline", () => {
|
|||||||
await (unmount as () => Promise<void>)();
|
await (unmount as () => Promise<void>)();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("declares its compiled stylesheet on the artifact rather than beside it", async () => {
|
||||||
|
const namespace = (await import(artifactHref)) as unknown;
|
||||||
|
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||||
|
|
||||||
|
// The module's own CSS *and* the shared elements' rules travel inside the one declared
|
||||||
|
// JavaScript artifact, as text (ADR 0040). A host installs them; nothing fetches a second file.
|
||||||
|
expect(definition.styles).toContain(".example-summary");
|
||||||
|
expect(definition.styles).toContain(".ek-panel");
|
||||||
|
expect(definition.styles).toContain("var(--ekklesia-text-secondary)");
|
||||||
|
// A module references the global theme and never declares part of it (ADR 0039).
|
||||||
|
expect(definition.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
|
||||||
|
await expect(readFile(path.join(path.dirname(artifactPath), "index.css"))).rejects.toThrow();
|
||||||
|
|
||||||
|
const displayNamespace = (await import(displayArtifactHref)) as unknown;
|
||||||
|
const displayDefinition = parseModuleUiExport(displayNamespace, manifestIdentity);
|
||||||
|
// Two artifacts of one module: separate roots, separate stylesheets.
|
||||||
|
expect(displayDefinition.styles).toContain(".example-display");
|
||||||
|
expect(displayDefinition.styles).not.toContain(".example-summary");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a denied state from the host's presentation-only permission hint", async () => {
|
it("renders a denied state from the host's presentation-only permission hint", async () => {
|
||||||
const namespace = (await import(artifactHref)) as unknown;
|
const namespace = (await import(artifactHref)) as unknown;
|
||||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||||
@@ -219,7 +239,7 @@ describe("module UI build pipeline", () => {
|
|||||||
);
|
);
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
expect(container.textContent).toContain("do not have access");
|
expect(container.textContent).toContain("No access to example records");
|
||||||
// A hidden control is a usability choice, not enforcement: the module simply did not ask.
|
// A hidden control is a usability choice, not enforcement: the module simply did not ask.
|
||||||
// Server-side authorization is what actually protects the route.
|
// Server-side authorization is what actually protects the route.
|
||||||
expect(requested).toEqual([]);
|
expect(requested).toEqual([]);
|
||||||
|
|||||||
@@ -4,5 +4,11 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts", "src/display/**/*.tsx", "src/display/**/*.ts"]
|
"include": [
|
||||||
|
"src/module-styles.d.ts",
|
||||||
|
"src/ui/**/*.tsx",
|
||||||
|
"src/ui/**/*.ts",
|
||||||
|
"src/display/**/*.tsx",
|
||||||
|
"src/display/**/*.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe("repository dependency boundaries", () => {
|
|||||||
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
|
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects stylesheet and case-insensitive view files inside a module, but not TSX", async () => {
|
it("rejects a misplaced stylesheet and case-insensitive view files inside a module, but not TSX", async () => {
|
||||||
const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
|
const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
|
||||||
const uiViolations = result.violations.filter(
|
const uiViolations = result.violations.filter(
|
||||||
(violation) => violation.rule === "modules-do-not-own-ui",
|
(violation) => violation.rule === "modules-do-not-own-ui",
|
||||||
@@ -70,10 +70,17 @@ describe("repository dependency boundaries", () => {
|
|||||||
expect(uiViolations).toHaveLength(2);
|
expect(uiViolations).toHaveLength(2);
|
||||||
expect(uiViolations.map((violation) => violation.from)).toEqual(
|
expect(uiViolations.map((violation) => violation.from)).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
|
// A stylesheet outside a compiled browser source root is a file no build reads and no host
|
||||||
|
// loads — the undeclared sidecar ADR 0023 rejected, just relocated (ADR 0040).
|
||||||
"modules/calendar/src/calendar.css",
|
"modules/calendar/src/calendar.css",
|
||||||
"modules/calendar/src/view.ASTRO",
|
"modules/calendar/src/view.ASTRO",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
// Inside `src/ui/`, a plain stylesheet is legitimate source the canonical builder compiles into
|
||||||
|
// the declared artifact.
|
||||||
|
expect(uiViolations.map((violation) => violation.from)).not.toContain(
|
||||||
|
"modules/calendar/src/ui/styles.css",
|
||||||
|
);
|
||||||
// React is the supported module UI authoring model (ADR 0022), so a module's own `.tsx` is
|
// React is the supported module UI authoring model (ADR 0022), so a module's own `.tsx` is
|
||||||
// legitimate source and must no longer be reported.
|
// legitimate source and must no longer be reported.
|
||||||
expect(uiViolations.map((violation) => violation.from)).not.toContain(
|
expect(uiViolations.map((violation) => violation.from)).not.toContain(
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/* Legal since ADR 0040: a plain stylesheet inside a compiled browser source root. */
|
||||||
|
.calendar-day {
|
||||||
|
color: var(--ekklesia-text-primary);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user