Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/rude-mangos-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@stack54/express": major
"@stack54/island": major
"@stack54/hono": major
"stack54": major
---

Migrate to svelte 5 and drop support for svelte 4
2 changes: 1 addition & 1 deletion integrations/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@types/node": "^20.10.6",
"express": "^4.19.2",
"typescript": "^5.3.3",
"vite": "^5.3.5"
"vite": "^6.3.5"
},
"exports": {
"./render": "./dist/render.js",
Expand Down
2 changes: 1 addition & 1 deletion integrations/express/src/view/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function engine(
try {
const view = typeof template == "string" ? template : await template;
const render = stream ? renderToStream : renderToString;
const html = render(view as Template, _props, { context });
const html = render(view as Template, { context, props: _props });
fn(null, html);
} catch (error) {
fn(error);
Expand Down
2 changes: 1 addition & 1 deletion integrations/hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@types/set-cookie-parser": "^2.4.7",
"hono": "^4.6.10",
"typescript": "^5.3.3",
"vite": "^5.3.5"
"vite": "^6.3.5"
},
"peerDependencies": {
"hono": "^3.12.0"
Expand Down
8 changes: 6 additions & 2 deletions integrations/island/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@
"author": "Joshua Amaju",
"license": "ISC",
"dependencies": {
"estree-walker": "^3.0.3",
"magic-string": "^0.30.5",
"magic-string-stack": "^0.1.1",
"stack54": "workspace:^",
"svelte": "^4.2.8",
"ts-dedent": "^2.2.0",
"vite": "^5.4.0"
"vite": "^6.3.5"
},
"exports": {
".": "./dist/index.js",
Expand All @@ -35,6 +35,10 @@
},
"devDependencies": {
"@types/node": "^20.10.6",
"svelte": "^5.34.5",
"typescript": "^5.3.3"
},
"peerDependencies": {
"svelte": "^5.34.5"
}
}
75 changes: 34 additions & 41 deletions integrations/island/src/hydrate.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,10 @@
import { SvelteComponent } from "svelte";
import { createRawSnippet, hydrate, unmount } from "svelte";
import type { Callback } from "./directives/types.js";

// @ts-expect-error no export type definition
import { detach, insert, noop } from "svelte/internal";

// 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 = {};

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>;
private instance?: Record<string, any>;

// connectedCallback() {
// if (
Expand Down Expand Up @@ -76,7 +49,7 @@ class Island extends HTMLElement {
this.hydrate();
}

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

Expand All @@ -89,13 +62,15 @@ class Island extends HTMLElement {
return;
}

const parent = this.parentElement?.closest("stack54-island");
let parent = this.closest("stack54-island");

if (this.isSameNode(parent)) {
parent = parent?.parentElement?.closest("stack54-island") || null;
}

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

Expand All @@ -111,20 +86,36 @@ class Island extends HTMLElement {
for (let slot of slots) {
const closest = slot.closest(this.tagName);
if (!closest?.isSameNode(this)) continue;
const name = slot.getAttribute("name") || "default";
const name = slot.getAttribute("name") || "children";
slotted.push([name, slot]);
}

const _props = {
...props,
$$scope: {},
$$slots: Object.fromEntries(
slotted.map(([k, _]) => [k, [slotty(_ as any)]])
...Object.fromEntries(
slotted.map(([k, slot]) => [
k,
createRawSnippet(() => {
return {
render() {
// Remove comments to avoid [issue](https://svelte.dev/docs/svelte/runtime-warnings#Client-warnings-hydration_mismatch)
return slot.innerHTML.replace(/<!--[\s\S]*?-->/g, "");
},
setup(element) {
return () => console.log("destroy");
},
};
}),
// (anchor: Comment) => {
// anchor.replaceWith(slot);
// return () => slot.remove();
// },
])
),
};

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

this.hydrated = true;
this.dispatchEvent(new CustomEvent("stack54:hydrate"));
Expand All @@ -133,7 +124,9 @@ class Island extends HTMLElement {
};

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

Expand Down
147 changes: 96 additions & 51 deletions integrations/island/src/island.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import MagicString from "magic-string";
import { dedent } from "ts-dedent";

import type { PreprocessorGroup } from "svelte/compiler";
import { parse, preprocess, walk } from "svelte/compiler";
import { BaseNode, Element } from "svelte/types/compiler/interfaces";
import { walk, type Node } from "estree-walker";
import type { AST, PreprocessorGroup } from "svelte/compiler";
import { parse, preprocess } from "svelte/compiler";

import { to_fs } from "stack54/internals";
import { ResolvedConfig } from "stack54/config";
import { to_fs } from "stack54/internals";

type Attributes = Record<string, string | boolean>;

Expand All @@ -17,14 +17,14 @@ type Slot = Loc & { name?: string };
type Head = Loc & { content: Loc };

function visit(
node: BaseNode,
visitor: (node: BaseNode) => BaseNode
): BaseNode {
if (node.children) {
node.children = node.children.map((_) => visit(_, visitor));
node: Node,
visitor: (node: AST.BaseNode) => AST.BaseNode
): AST.BaseNode {
if ("children" in node) {
node.children = (node.children as Node[]).map((_) => visit(_, visitor));
}

return visitor(node);
return visitor(node as AST.BaseNode);
}

const KEY = "island";
Expand Down Expand Up @@ -68,51 +68,72 @@ export async function make(

const s = new MagicString(processed.code);

let props: Array<{ name: string; kind: string }> = [];

for (const node of ast.instance.content.body) {
if (
node.type == "ExportNamedDeclaration" &&
node.declaration?.type == "VariableDeclaration"
) {
const { kind } = node.declaration;
let snippets: Array<Slot> = [];
let svelte_head: Head | undefined;

const [declaration] = node.declaration.declarations;
let props = "{}";

if (declaration.id.type == "ObjectPattern") {
for (const property of declaration.id.properties) {
if (
property.type == "Property" &&
property.key.type == "Identifier"
) {
props.push({ kind, name: property.key.name });
// Look for props `$props()`
for (const node of ast.instance.content.body) {
let should_continue = true;

if (node.type === "VariableDeclaration" && node.declarations.length > 0) {
for (const decl of node.declarations) {
if (
decl.init &&
decl.init.type === "CallExpression" &&
decl.init.callee.type == "Identifier" &&
decl.init.callee.name === "$props"
) {
// Simple `const props = $props()`
if (decl.id.type == "Identifier") {
props = decl.id.name;
}
}
}

if (declaration.id.type == "ArrayPattern") {
for (const element of declaration.id.elements) {
if (element?.type == "Identifier") {
props.push({ kind, name: element.name });
// `const {prop1, prop2: prop_rename, prop2: prop_rename_with_value = 'value', ...rest} = $props()`
if (decl.id.type === "ObjectPattern") {
let members = [];

for (const prop of decl.id.properties) {
if (prop.type === "Property") {
if (prop.key.type == "Identifier") {
const { value: val } = prop;
const value =
val.type == "Identifier"
? val
: val.type == "AssignmentPattern"
? val.left
: null;

if (value?.type == "Identifier") {
// Just in-case the prop get renamed during destructuring
members.push(`${prop.key.name}: ${value.name}`);
}
}
} else {
// `const { /*...*/, ...rest} = $props()`
if (prop.argument.type == "Identifier") {
members.push("..." + prop.argument.name);
}
}
}

props = `{${members.join(", ")}}`;
}
}
}

if (declaration.id.type == "Identifier") {
props.push({ kind, name: declaration.id.name });
should_continue = false;
break;
}
}
}
}

const slots: Array<Slot> = [];
let svelte_head: Head | undefined;
if (!should_continue) break;
}

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

if (node_.type == "Head") {
const first = node_.children?.[0];
Expand All @@ -127,14 +148,28 @@ export async function make(
}
}

if (node.type == "Slot") {
const name_attr = node_.attributes.find(
(attr) => attr.type == "Attribute" && attr.name == "name"
);
if (node.type == "RenderTag") {
const tag = node as AST.RenderTag;

const name = name_attr?.value.find((val: any) => val.type == "Text");
// Snippet `@render name()`
if (
tag.expression.type == "CallExpression" &&
tag.expression.callee.type == "MemberExpression" &&
tag.expression.callee.property.type == "Identifier"
) {
const { name } = tag.expression.callee.property;
snippets.push({ ...tag, name });
}

slots.push({ ...node, name: name?.data });
// Snippet with optional chaining `@render name?.()`
if (
tag.expression.type == "ChainExpression" &&
tag.expression.expression.type == "CallExpression" &&
tag.expression.expression.callee.type == "Identifier"
) {
const { name } = tag.expression.expression.callee;
snippets.push({ ...tag, name });
}
}

return node;
Expand Down Expand Up @@ -170,7 +205,11 @@ export async function make(
s.overwrite(start, end, "");
}

slots.forEach(({ end, start, name }) => {
snippets = Object.values(
Object.fromEntries(snippets.map((_) => [_.name, _]))
);

snippets.forEach(({ end, start, name }) => {
const slot = s.slice(start, end);
const attr = name ? `name="${name}"` : "";
const content = `<stack54-slot style="display:contents;" ${attr}>${slot}</stack54-slot>`;
Expand All @@ -179,13 +218,19 @@ export async function make(

const markup = s.toString().replace(SFC_script_style, "");

// Remove snippets from collected props to avoid JSON serialization error
const code_ = dedent/*html*/ `
${module}

<script ${attributes.join(" ")}>
import {stringify} from "stack54/data";
${script.content}
const __props__ = {${props.map((prop) => prop.name).join(",")}};

const __props__ = ${`Object.fromEntries(
Object.entries(${props})
.map(([k, v]) => [k, typeof v == 'function' ? null : v])
.filter(([, v]) => v !== null)
)`}
</script>

<svelte:head>
Expand Down
Loading