feat(compiler): add island marker codegen and props contract
Emits the data-wrn-island placeholder, parses client:* strategies, and rejects non-serializable props at compile time via WRN-ISLAND-PROPS so the serialization boundary fails where it is cheapest to fix. Island names become URL path segments when the browser fetches the island bundle, so they are validated with core's existing isSafeIslandName rather than relying on escaping alone. This adds @wrnexus/core to the compiler's dependencies; core has no dependencies of its own, so no cycle is introduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
parseIslandStrategy,
|
||||
renderIslandMarker,
|
||||
serializeIslandProps,
|
||||
} from "../src/island-codegen.ts";
|
||||
|
||||
test("defaults to the client-only strategy", () => {
|
||||
expect(parseIslandStrategy([])).toBe("only");
|
||||
expect(parseIslandStrategy(["client:visible"])).toBe("visible");
|
||||
expect(parseIslandStrategy(["client:idle"])).toBe("idle");
|
||||
expect(parseIslandStrategy(["client:load"])).toBe("load");
|
||||
});
|
||||
|
||||
test("serializes JSON-safe props", () => {
|
||||
const result = serializeIslandProps("Chart", { title: "Revenue", points: [1, 2] });
|
||||
expect(result).toEqual({ json: '{"title":"Revenue","points":[1,2]}' });
|
||||
});
|
||||
|
||||
test("rejects non-serializable props with WRN-ISLAND-PROPS", () => {
|
||||
const result = serializeIslandProps("Chart", { onClick: () => {} });
|
||||
expect(result).toHaveProperty("diagnostic");
|
||||
const { diagnostic } = result as { diagnostic: { code: string; message: string } };
|
||||
expect(diagnostic.code).toBe("WRN-ISLAND-PROPS");
|
||||
expect(diagnostic.message).toContain("Chart");
|
||||
expect(diagnostic.message).toContain("onClick");
|
||||
});
|
||||
|
||||
test("rejects class instances and nested offenders", () => {
|
||||
class Point {
|
||||
constructor(public x = 1) {}
|
||||
}
|
||||
expect(serializeIslandProps("Chart", { origin: new Point() })).toHaveProperty("diagnostic");
|
||||
expect(serializeIslandProps("Chart", { nested: { deep: () => {} } })).toHaveProperty(
|
||||
"diagnostic",
|
||||
);
|
||||
expect(serializeIslandProps("Chart", { list: [1, () => {}] })).toHaveProperty("diagnostic");
|
||||
});
|
||||
|
||||
test("accepts null and nested plain data", () => {
|
||||
const result = serializeIslandProps("Chart", {
|
||||
empty: null,
|
||||
nested: { rows: [{ id: 1 }], flag: false },
|
||||
});
|
||||
expect(result).toHaveProperty("json");
|
||||
});
|
||||
|
||||
test("rejects island names that are unsafe as URL path segments", () => {
|
||||
// The name is fetched as /__wrnexus/island/<name>.js, so traversal and
|
||||
// separators must be refused rather than merely escaped.
|
||||
expect(() =>
|
||||
renderIslandMarker({ name: "../secret", strategy: "only", propsJson: "{}" }),
|
||||
).toThrow(/not a safe identifier/);
|
||||
expect(() => renderIslandMarker({ name: "a/b", strategy: "only", propsJson: "{}" })).toThrow(
|
||||
/not a safe identifier/,
|
||||
);
|
||||
expect(() =>
|
||||
renderIslandMarker({ name: "Chart", strategy: "only", propsJson: "{}" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("renders a marker with escaped props", () => {
|
||||
const html = renderIslandMarker({
|
||||
name: "Chart",
|
||||
strategy: "visible",
|
||||
propsJson: '{"title":"a<b\\"c"}',
|
||||
});
|
||||
expect(html).toContain('data-wrn-island="Chart"');
|
||||
expect(html).toContain('data-wrn-island-strategy="visible"');
|
||||
expect(html).not.toContain('title":"a<b"c');
|
||||
expect(html).toContain("<");
|
||||
expect(html).toContain(""");
|
||||
});
|
||||
Reference in New Issue
Block a user