Skip to content

Commit

Permalink
feat: support islands having slots, including named slots (#22)
Browse files Browse the repository at this point in the history
* feat: support islands having slots, including named slots
  • Loading branch information
joshamaju authored Sep 21, 2024
1 parent f92191f commit e1c8b83
Show file tree
Hide file tree
Showing 14 changed files with 541 additions and 6,198 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
- run: pnpm install
- run: pnpm build
# - run: pnpm run test
- run: pnpm run test

- name: create release pull request or publish
uses: changesets/action@v1
Expand Down
5 changes: 4 additions & 1 deletion integrations/island/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"author": "Joshua Amaju",
"license": "ISC",
"dependencies": {
"magic-string": "^0.30.5",
"magic-string-stack": "^0.1.1",
"stack54": "workspace:^",
"svelte": "^4.2.8",
"ts-dedent": "^2.2.0",
Expand All @@ -32,6 +34,7 @@
"./directives": "./dist/directives/index.js"
},
"devDependencies": {
"@types/node": "^20.10.6"
"@types/node": "^20.10.6",
"typescript": "^5.3.3"
}
}
149 changes: 132 additions & 17 deletions integrations/island/src/hydrate.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,142 @@
import type { SvelteComponent } from "svelte";
import { SvelteComponent } from "svelte";
import type { Callback } from "./directives/types.js";

export function hydrate(load: Callback) {
class Island extends HTMLElement {
private instance?: SvelteComponent<any, any, any>;
// @ts-expect-error no export type definition
import { detach, insert, noop } from "svelte/internal";

connectedCallback() {
const target = this;
// https://github.com/lukeed/freshie/blob/5930c2eb8008aac93dcdad1da730e620db327072/packages/%40freshie/ui.svelte/index.js#L20
function slotty(elem: Element | Comment) {
return function (...args: any[]) {
let frag: any = {};

load((Component) => {
import("stack54/data").then(({ raw_decode }) => {
const props = raw_decode(this.getAttribute("props")!) as any;
this.instance = new Component({ props, target, hydrate: true });
});
}, target);
frag.c = frag.c || noop;
frag.l = frag.l || noop;

frag.m =
frag.m ||
function (target: any, anchor: any) {
insert(target, elem, anchor);
};

frag.d =
frag.d ||
function (detaching: any) {
if (detaching) detach(elem);
};

return frag;
};
}

class Island extends HTMLElement {
hydrated = false;

private instance?: SvelteComponent<any, any, any>;

// connectedCallback() {
// if (
// !this.hasAttribute("await-children") ||
// document.readyState === "interactive" ||
// document.readyState === "complete"
// ) {
// this.childrenConnectedCallback();
// } else {
// // connectedCallback may run *before* children are rendered (ex. HTML streaming)
// // If SSR children are expected, but not yet rendered, wait with a mutation observer
// // for a special marker inserted when rendering islands that signals the end of the island
// const onConnected = () => {
// document.removeEventListener("DOMContentLoaded", onConnected);
// mo.disconnect();
// this.childrenConnectedCallback();
// };

// const mo = new MutationObserver(() => {
// if (
// this.lastChild?.nodeType === Node.COMMENT_NODE &&
// this.lastChild.nodeValue === "astro:end"
// ) {
// this.lastChild.remove();
// onConnected();
// }
// });

// mo.observe(this, { childList: true });

// // in case the marker comment got stripped and the mutation observer waited indefinitely,
// // also wait for DOMContentLoaded as a last resort
// document.addEventListener("DOMContentLoaded", onConnected);
// }
// }

// childrenConnectedCallback() {
// this.hydrate();
// }

connectedCallback() {
this.hydrate();
}

hydrate = () => {
const file = this.getAttribute("file");
const directive = this.getAttribute("directive");

const load: Callback = window[file! as keyof Window];

if (load === undefined) {
window.addEventListener(`stack54:${directive}`, this.hydrate, {
once: true,
});
return;
}

disconnectedCallback() {
this.instance?.$destroy();
const parent = this.parentElement?.closest("stack54-island");

// @ts-expect-error
if (parent && !parent.hydrated) {
parent.addEventListener("stack54:hydrate", this.hydrate, {
once: true,
});
return;
}
}

if (!customElements.get("stack54-island")) {
customElements.define("stack54-island", Island);
const target = this;

load((Component) => {
import("stack54/data").then(({ raw_decode }) => {
const props = raw_decode(this.getAttribute("props")!);
const slots = this.querySelectorAll("stack54-slot");

const slotted: Array<[string, Element]> = [];

for (let slot of slots) {
const closest = slot.closest(this.tagName);
if (!closest?.isSameNode(this)) continue;
const name = slot.getAttribute("name") || "default";
slotted.push([name, slot]);
}

const _props = {
...props,
$$scope: {},
$$slots: Object.fromEntries(
slotted.map(([k, _]) => [k, [slotty(_ as any)]])
),
};

const opts = { target, props: _props, hydrate: true, $$inline: true };
this.instance = new Component(opts);

this.hydrated = true;
this.dispatchEvent(new CustomEvent("stack54:hydrate"));
});
}, target);
};

disconnectedCallback() {
this.instance?.$destroy();
}
}

if (!customElements.get("stack54-island")) {
customElements.define("stack54-island", Island);
}
73 changes: 64 additions & 9 deletions integrations/island/src/process.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import MagicStringStack from "magic-string-stack";
import { dedent } from "ts-dedent";

import type { PreprocessorGroup } from "svelte/compiler";
import { parse, preprocess } from "svelte/compiler";
import { parse, preprocess, walk } from "svelte/compiler";
import { BaseNode, Element } from "svelte/types/compiler/interfaces";

import { ResolvedConfig } from "stack54/config";
import { arraify, to_fs } from "stack54/internals";
Expand All @@ -10,6 +12,12 @@ type Attributes = Record<string, string | boolean>;

type Block = { content: string; attributes: Attributes };

type Slot = {
end: number;
start: number;
name?: string;
};

const makeAttrs = (attrs: Attributes) => {
return Object.entries(attrs).map(([k, v]) =>
typeof v == "boolean" ? `${k}` : `${k}="${v}"`
Expand Down Expand Up @@ -96,10 +104,55 @@ export async function makeIsland(
}
}

const markup = processed.code.replace(
/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/g,
""
);
function visit(
node: BaseNode,
visitor: (node: BaseNode) => BaseNode
): BaseNode {
if (node.children) {
node.children = node.children.map((_) => visit(_, visitor));
}

return visitor(node);
}

const slots: Array<Slot> = [];

// @ts-expect-error
walk(ast.html, {
enter(node) {
// @ts-expect-error
visit(node, (node) => {
if (node.type == "Slot") {
const node_: Element = node as any;

const name_attr = node_.attributes.find(
(attr) => attr.type == "Attribute" && attr.name == "name"
);

const name = name_attr?.value.find(
(val: any) => val.type == "Text"
);

slots.push({ ...node, name: name?.data });
}
});
},
});

const ms = new MagicStringStack(processed.code);

slots.forEach(({ end, start, name }) => {
const slot = ms.slice(start, end);
const name_ = name ? `name="${name}"` : "";
const content = `<stack54-slot style="display:contents;" ${name_}>${slot}</stack54-slot>`;
ms.overwrite(start, end, content);
});

ms.commit();

const markup = ms
.toString()
.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/g, "");

delete island.attributes[KEY];
delete island.attributes[CONFIG];
Expand All @@ -108,7 +161,7 @@ export async function makeIsland(

const value = opts ? `${JSON.stringify(opts)}` : "undefined";

const script = dedent`
const script = dedent/*html*/ `
${module ? makeBlock("script", module) : ""}
<script ${attributes.join(" ")}>
Expand All @@ -121,16 +174,18 @@ export async function makeIsland(
<svelte:head>
<script type="module">
import { hydrate } from '@stack54/island/hydrate';
import '@stack54/island/hydrate';
import * as directives from "@stack54/island/directives";
const directive = directives["${directive}"];
const load = () => import("${to_fs(filename)}");
hydrate(directive(load, {value: ${value}}));
window["${filename}"] = directive(load, {value: ${value}});
window.dispatchEvent(new Event("stack54:${directive}"));
</script>
</svelte:head>
<stack54-island style="display:contents;" props="{raw_encode(__serialized__)}">
<stack54-island file="${filename}" directive="${directive}" style="display:contents;" props="{raw_encode(__serialized__)}">
${markup}
</stack54-island>
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/apps/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@hono/node-server": "^1.12.0",
"@stack54/express": "workspace:*",
"@stack54/island": "workspace:*",
"express": "^4.19.2",
"hono": "^4.5.5",
"stack54": "workspace:*",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/test/apps/basic/src/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ app.get("/env/client", (_, res) => res.render("env/client"));
app.get("/env/public", (_, res) => res.render("env/public"));
app.get("/env/in-view", (_, res) => res.render("env/in-view"));
app.get("/env/server", (_, res) => {
// @ts-expect-error
return res.render("env/server", { env: import.meta.env.ENV });
});

app.get("/island/no-slot", (_, res) => res.render("island/no-slot.page"));
app.get("/island/with-slot", (_, res) => res.render("island/with-slot.page"));

export default app;
9 changes: 9 additions & 0 deletions packages/core/test/apps/basic/src/views/island/counter.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script>
let count = 0;
</script>

<button data-testid="dec" on:click="{() => (count = count - 1)}">-</button>

<p data-testid="text">{count}</p>

<button data-testid="inc" on:click="{() => (count = count + 1)}">+</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script lang="ts">
import Document from "../components/document.svelte";
import NoSlot from "./no-slot.svelte";
</script>

<Document>
<NoSlot />
</Document>
10 changes: 10 additions & 0 deletions packages/core/test/apps/basic/src/views/island/no-slot.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script island="load" lang="ts">
import { onMount } from "svelte";
import Counter from "./counter.svelte";
let name: string | undefined;
onMount(() => (name = "no slot"));
</script>

<p data-testid="no-slot">{name}</p>

<Counter />
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script>
import Document from "../components/document.svelte";
import WithSlot from "./with-slot.svelte";
</script>

<Document>
<WithSlot>
<div>
<p>default</p>
</div>

<div slot="named">
<p>named</p>
</div>
</WithSlot>
</Document>
13 changes: 13 additions & 0 deletions packages/core/test/apps/basic/src/views/island/with-slot.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script island="load">
import Counter from "./counter.svelte";
</script>

<Counter />

<div data-testid="default-slot">
<slot />
</div>

<div data-testid="named-slot">
<slot name="named" />
</div>
3 changes: 2 additions & 1 deletion packages/core/test/apps/basic/stack.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from "stack54/config";
import express from "@stack54/express/plugin";
import island from "@stack54/island"

export default defineConfig({
integrations: [express()],
integrations: [express(), island()],
});
Loading

0 comments on commit e1c8b83

Please sign in to comment.