diff --git a/demo/src/components/entry.js b/demo/src/components/entry.js
deleted file mode 100644
index 1d4518c..0000000
--- a/demo/src/components/entry.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { mount, unmount } from "svelte";
-import Simple from "./simple.svelte";
-/**
- * The factory function is used to create a new instance of the component
- * when being rendered on the receiving side.
- *
- * This is important because it allows us to have granular control over the component
- * lifecycle and not require the receiving side to bear that burden.
- *
- * @param {HTMLElement} target The target element to mount the component on.
- * @param {SimpleProps} props The props to pass to the component.
- *
- * @returns {Rendered} A Rendered object that contains the component, name, props, and destroy function.
- */
-const factory = (target, props) => {
- const component = mount(Simple, {
- target,
- props: props
- });
- return {
- component,
- name: "Simple",
- props: props,
- destroy: () => {
- console.log("entry.ts -> simple.svelte", "destroying component", component);
- unmount(component);
- }
- };
-};
-/**
- * Export the factory function as the default export to make it easier
- * on the receiving side performing the dynamic import.
- */
-export { factory as default };
-//# sourceMappingURL=entry.js.map
\ No newline at end of file
diff --git a/demo/src/components/entry.js.map b/demo/src/components/entry.js.map
deleted file mode 100644
index 2909901..0000000
--- a/demo/src/components/entry.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"entry.js","sourceRoot":"","sources":["entry.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,OAAO,EAAuB,MAAM,QAAQ,CAAC;AAC7D,OAAO,MAAM,MAAM,iBAAiB,CAAC;AAQrC;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,GAAG,CAAC,MAAmB,EAAE,KAAmB,EAAyB,EAAE;IAClF,MAAM,SAAS,GAAG,KAAK,CAAC,MAAM,EAAE;QAC9B,MAAM;QACN,KAAK,EAAE,KAAoB;KAC5B,CAAC,CAAC;IAEH,OAAO;QACL,SAAS;QACT,IAAI,EAAE,QAAQ;QACd,KAAK,EAAE,KAAoB;QAC3B,OAAO,EAAE,GAAG,EAAE;YACZ,OAAO,CAAC,GAAG,CAAC,2BAA2B,EAAE,sBAAsB,EAAE,SAAS,CAAC,CAAC;YAC5E,OAAO,CAAC,SAAS,CAAC,CAAC;QACrB,CAAC;KACF,CAAC;AACJ,CAAC,CAAC;AAEF;;;GAGG;AACH,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,CAAC"}
\ No newline at end of file
diff --git a/demo/src/components/index.js b/demo/src/components/index.js
deleted file mode 100644
index 5035086..0000000
--- a/demo/src/components/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from "./entry";
-export { default as Simple } from "./simple.svelte";
-//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/demo/src/components/index.js.map b/demo/src/components/index.js.map
deleted file mode 100644
index 1e91985..0000000
--- a/demo/src/components/index.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,iBAAiB,CAAC"}
\ No newline at end of file
diff --git a/demo/src/components/index.ts b/demo/src/components/index.ts
deleted file mode 100644
index d7f5885..0000000
--- a/demo/src/components/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./entry";
-export { default as Simple } from "./simple.svelte";
diff --git a/demo/src/components/simple.svelte b/demo/src/components/simple.svelte
deleted file mode 100644
index a572aa2..0000000
--- a/demo/src/components/simple.svelte
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
Simple Component
-
This is a simple component that is rendered on the receiving side. See the source code for more details.
-
-
- `name` from $props():
- "{name}"
-
-
- testState:
- {testState}
-
-
-
diff --git a/demo/src/extras/hooks/use-clipboard.svelte.ts b/demo/src/extras/hooks/use-clipboard.svelte.ts
deleted file mode 100644
index 3bcad7c..0000000
--- a/demo/src/extras/hooks/use-clipboard.svelte.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- Installed from @ieedan/shadcn-svelte-extras
-*/
-
-type Options = {
- /** The time before the copied status is reset. */
- delay: number;
-};
-
-/** Use this hook to copy text to the clipboard and show a copied state.
- *
- * ## Usage
- * ```svelte
- *
- *
- *
- * ```
- *
- */
-export class UseClipboard {
- #copiedStatus = $state<"success" | "failure">();
- private delay: number;
- private timeout: ReturnType | undefined = undefined;
-
- constructor({ delay = 500 }: Partial = {}) {
- this.delay = delay;
- }
-
- /** Copies the given text to the users clipboard.
- *
- * ## Usage
- * ```ts
- * clipboard.copy('Hello, World!');
- * ```
- *
- * @param text
- * @returns
- */
- async copy(text: string) {
- if (this.timeout) {
- this.#copiedStatus = undefined;
- clearTimeout(this.timeout);
- }
-
- try {
- await navigator.clipboard.writeText(text);
-
- this.#copiedStatus = "success";
-
- this.timeout = setTimeout(() => {
- this.#copiedStatus = undefined;
- }, this.delay);
- } catch {
- // an error can occur when not in the browser or if the user hasn't given clipboard access
- this.#copiedStatus = "failure";
-
- this.timeout = setTimeout(() => {
- this.#copiedStatus = undefined;
- }, this.delay);
- }
-
- return this.#copiedStatus;
- }
-
- /** true when the user has just copied to the clipboard. */
- get copied() {
- return this.#copiedStatus === "success";
- }
-
- /** Indicates whether a copy has occurred
- * and gives a status of either `success` or `failure`. */
- get status() {
- return this.#copiedStatus;
- }
-}
diff --git a/demo/src/extras/ui/button/button.svelte b/demo/src/extras/ui/button/button.svelte
deleted file mode 100644
index b78cc58..0000000
--- a/demo/src/extras/ui/button/button.svelte
+++ /dev/null
@@ -1,111 +0,0 @@
-
-
-
-
-
-
-
- {
- onclick?.(e);
-
- if (type === undefined) return;
-
- if (onClickPromise) {
- loading = true;
-
- await onClickPromise(e);
-
- loading = false;
- }
- }}>
- {#if type !== undefined && loading}
-
-
-
-
-
- Loading
- {/if}
- {@render children?.()}
-
diff --git a/demo/src/extras/ui/button/index.ts b/demo/src/extras/ui/button/index.ts
deleted file mode 100644
index bd53eb3..0000000
--- a/demo/src/extras/ui/button/index.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- Installed from @ieedan/shadcn-svelte-extras
-*/
-
-import Root, { type ButtonProps, type ButtonSize, type ButtonVariant, type AnchorElementProps, type ButtonElementProps, type ButtonPropsWithoutHTML, buttonVariants } from "./button.svelte";
-
-export {
- Root,
- type ButtonProps as Props,
- //
- Root as Button,
- buttonVariants,
- type ButtonProps,
- type ButtonSize,
- type ButtonVariant,
- type AnchorElementProps,
- type ButtonElementProps,
- type ButtonPropsWithoutHTML
-};
diff --git a/demo/src/extras/ui/copy-button/copy-button.svelte b/demo/src/extras/ui/copy-button/copy-button.svelte
deleted file mode 100644
index 7ca761e..0000000
--- a/demo/src/extras/ui/copy-button/copy-button.svelte
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
-
-
diff --git a/demo/src/extras/ui/copy-button/index.ts b/demo/src/extras/ui/copy-button/index.ts
deleted file mode 100644
index 20047fa..0000000
--- a/demo/src/extras/ui/copy-button/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/*
- Installed from @ieedan/shadcn-svelte-extras
-*/
-
-import CopyButton from "./copy-button.svelte";
-
-export { CopyButton };
diff --git a/demo/src/extras/ui/copy-button/types.ts b/demo/src/extras/ui/copy-button/types.ts
deleted file mode 100644
index 7683a1d..0000000
--- a/demo/src/extras/ui/copy-button/types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- Installed from @ieedan/shadcn-svelte-extras
-*/
-
-import type { Snippet } from "svelte";
-import type { ButtonPropsWithoutHTML } from "../button";
-import type { UseClipboard } from "../../hooks/use-clipboard.svelte";
-import type { HTMLAttributes } from "svelte/elements";
-import type { WithChildren, WithoutChildren } from "bits-ui";
-
-export type CopyButtonPropsWithoutHTML = WithChildren<
- Pick & {
- ref?: HTMLButtonElement | null;
- text: string;
- icon?: Snippet<[]>;
- animationDuration?: number;
- onCopy?: (status: UseClipboard["status"]) => void;
- }
->;
-
-export type CopyButtonProps = CopyButtonPropsWithoutHTML & WithoutChildren>;
diff --git a/demo/src/extras/utils/utils.ts b/demo/src/extras/utils/utils.ts
deleted file mode 100644
index 1ff3140..0000000
--- a/demo/src/extras/utils/utils.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- Installed from @ieedan/shadcn-svelte-extras
-*/
-
-import { type ClassValue, clsx } from "clsx";
-import { twMerge } from "tailwind-merge";
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type WithoutChild = T extends { child?: any } ? Omit : T;
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type WithoutChildren = T extends { children?: any } ? Omit : T;
-export type WithoutChildrenOrChild = WithoutChildren>;
-export type WithElementRef = T & { ref?: U | null };
diff --git a/demo/src/global.d.ts b/demo/src/global.d.ts
new file mode 100644
index 0000000..766ac9d
--- /dev/null
+++ b/demo/src/global.d.ts
@@ -0,0 +1,7 @@
+import type { Version } from "./lib/version/types";
+
+declare global {
+ const __VERSION__: Version | undefined;
+}
+
+export {};
diff --git a/demo/src/lib/clipboard.ts b/demo/src/lib/clipboard.ts
new file mode 100644
index 0000000..c3d2a07
--- /dev/null
+++ b/demo/src/lib/clipboard.ts
@@ -0,0 +1,115 @@
+// src/lib/actions/clipboard.ts
+
+import type { Attachment } from "svelte/attachments";
+
+export type ClipboardStatus = "idle" | "success" | "failure";
+
+export interface ClipboardOptions {
+ /** The time in milliseconds before the status resets to idle (default: 2000) */
+ delay?: number;
+ /** Callback when copy succeeds */
+ onSuccess?: (text: string) => void;
+ /** Callback when copy fails */
+ onFailure?: (error: Error) => void;
+ /** Callback when status changes */
+ onStatusChange?: (status: ClipboardStatus) => void;
+}
+
+interface ClipboardState {
+ status: ClipboardStatus;
+ timeout?: ReturnType;
+}
+
+/**
+ * Svelte 5 attachment action for copying text to clipboard
+ *
+ * @example
+ * ```svelte
+ *
+ *
+ *
+ * ```
+ */
+export const clipboard = (value: any, options: ClipboardOptions): Attachment => {
+ return (node: HTMLElement) => {
+ const state: ClipboardState = {
+ status: "idle",
+ timeout: undefined
+ };
+
+ const updateStatus = (newStatus: ClipboardStatus) => {
+ state.status = newStatus;
+ options.onStatusChange?.(newStatus);
+ };
+
+ const resetStatus = () => {
+ if (state.timeout) {
+ clearTimeout(state.timeout);
+ state.timeout = undefined;
+ }
+
+ const delay = options.delay ?? 2000;
+
+ state.timeout = setTimeout(() => {
+ updateStatus("idle");
+ state.timeout = undefined;
+ }, delay);
+ };
+
+ const handleClick = async (event: MouseEvent) => {
+ // Prevent default if it's a button inside a form
+ if (node.tagName === "BUTTON" && node.closest("form")) {
+ event.preventDefault();
+ }
+
+ // Clear any existing timeout
+ if (state.timeout) {
+ clearTimeout(state.timeout);
+ state.timeout = undefined;
+ }
+
+ try {
+ await navigator.clipboard.writeText(value);
+ updateStatus("success");
+ options.onSuccess?.(value);
+ resetStatus();
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error("Failed to copy to clipboard");
+ updateStatus("failure");
+ options.onFailure?.(err);
+ resetStatus();
+ }
+ };
+
+ // Add click listener
+ node.addEventListener("click", handleClick);
+
+ // Add appropriate ARIA attributes
+ node.setAttribute("role", "button");
+ node.setAttribute("aria-label", "Copy to clipboard");
+
+ // Add cursor style if not already set
+ if (!node.style.cursor) {
+ node.style.cursor = "pointer";
+ }
+
+ return () => {
+ if (state.timeout) {
+ clearTimeout(state.timeout);
+ }
+ node.removeEventListener("click", handleClick);
+ node.removeAttribute("role");
+ node.removeAttribute("aria-label");
+ };
+ };
+};
diff --git a/demo/src/lib/components/ui/button/index.js b/demo/src/lib/components/ui/button/index.js
deleted file mode 100644
index e727484..0000000
--- a/demo/src/lib/components/ui/button/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import Root, { buttonVariants, } from "./button.svelte";
-export { Root,
-//
-Root as Button, buttonVariants, };
-//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/demo/src/lib/components/ui/button/index.js.map b/demo/src/lib/components/ui/button/index.js.map
deleted file mode 100644
index 9d6e764..0000000
--- a/demo/src/lib/components/ui/button/index.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,EAAE,EAIZ,cAAc,GACd,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EACN,IAAI;AAEJ,EAAE;AACF,IAAI,IAAI,MAAM,EACd,cAAc,GAId,CAAC"}
\ No newline at end of file
diff --git a/demo/src/lib/components/ui/sonner/index.js b/demo/src/lib/components/ui/sonner/index.js
deleted file mode 100644
index 55afb49..0000000
--- a/demo/src/lib/components/ui/sonner/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default as Toaster } from "./sonner.svelte";
-//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/demo/src/lib/components/ui/sonner/index.js.map b/demo/src/lib/components/ui/sonner/index.js.map
deleted file mode 100644
index 2a11a9e..0000000
--- a/demo/src/lib/components/ui/sonner/index.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,iBAAiB,CAAC"}
\ No newline at end of file
diff --git a/src/lib/logger.ts b/demo/src/lib/logger.ts
similarity index 100%
rename from src/lib/logger.ts
rename to demo/src/lib/logger.ts
diff --git a/demo/src/lib/utils.js b/demo/src/lib/utils.js
deleted file mode 100644
index ad49281..0000000
--- a/demo/src/lib/utils.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { clsx } from "clsx";
-import { twMerge } from "tailwind-merge";
-export function cn(...inputs) {
- return twMerge(clsx(inputs));
-}
-//# sourceMappingURL=utils.js.map
\ No newline at end of file
diff --git a/demo/src/lib/utils.js.map b/demo/src/lib/utils.js.map
deleted file mode 100644
index 84cf4e1..0000000
--- a/demo/src/lib/utils.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"utils.js","sourceRoot":"","sources":["utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,MAAM,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAEzC,MAAM,UAAU,EAAE,CAAC,GAAG,MAAoB;IACzC,OAAO,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;AAC9B,CAAC"}
\ No newline at end of file
diff --git a/demo/src/lib/version/browser.ts b/demo/src/lib/version/browser.ts
new file mode 100644
index 0000000..a56e3d8
--- /dev/null
+++ b/demo/src/lib/version/browser.ts
@@ -0,0 +1,48 @@
+import type { Version } from "./types";
+import type { VersionConfig } from "./vite-plugin-version";
+
+declare const __VERSION__: Version | undefined;
+
+export const getVersion = (config?: VersionConfig): Version => {
+ if (config?.debug) {
+ console.log("getVersion()", "checking available version sources");
+ }
+
+ if (typeof __VERSION__ !== "undefined") {
+ if (config?.debug) {
+ console.log("getVersion()", "Found __VERSION__ from build-time replacement:", __VERSION__);
+ }
+ return __VERSION__;
+ }
+
+ if (import.meta.env.VITE_VERSION) {
+ try {
+ const parsed = JSON.parse(import.meta.env.VITE_VERSION as string);
+ if (config?.debug) {
+ console.log("getVersion()", "Found VITE_VERSION from import.meta.env:", parsed);
+ }
+ return parsed;
+ } catch (e) {
+ console.error("getVersion()", "Failed to parse VITE_VERSION:", e);
+ }
+ }
+
+ // Fallback
+ if (config?.debug) {
+ console.error("getVersion()", "no version information available");
+ }
+ return {
+ location: null,
+ tag: "unknown",
+ commit: {
+ long: "unknown",
+ short: "unknown"
+ },
+ dirty: true,
+ branch: "unknown",
+ date: {
+ actual: new Date(1970, 1, 1),
+ human: "unknown"
+ }
+ };
+};
diff --git a/demo/src/lib/version/exec.ts b/demo/src/lib/version/exec.ts
new file mode 100644
index 0000000..65ac7c9
--- /dev/null
+++ b/demo/src/lib/version/exec.ts
@@ -0,0 +1,23 @@
+import { spawnSync } from "node:child_process";
+
+export const exec = (
+ command: string
+): {
+ output: string;
+ status: number | null;
+} => {
+ const result = spawnSync(command, {
+ cwd: process.cwd(),
+ shell: true
+ });
+ if (result.status !== 0) {
+ return {
+ output: result.stderr.toString().trim(),
+ status: result.status
+ };
+ }
+ return {
+ output: result.stdout.toString().trim(),
+ status: result.status
+ };
+};
diff --git a/demo/src/lib/version/git.ts b/demo/src/lib/version/git.ts
new file mode 100644
index 0000000..24b5515
--- /dev/null
+++ b/demo/src/lib/version/git.ts
@@ -0,0 +1,58 @@
+import { exec } from "./exec";
+import type { Version } from "./types";
+
+export type GitLookup = "tag" | "commit" | "branch" | "dirty" | "date";
+
+export const git = {
+ status: () => exec("git status")?.output,
+ tag: () => exec("git describe --tags --abbrev=0")?.output,
+ fetch: () => exec("git fetch --all -v ")?.output,
+ log: () => exec("git log --graph -10 --branches --remotes --tags --format=format:'%Cgreen%h %Creset• %s (%cN, %cr) %Cred%d' --date-order")?.output,
+ date: () => exec("git log -1 --format=%cd")?.output,
+ commit: {
+ long: () => exec("git rev-parse HEAD")?.output,
+ short: () => exec("git rev-parse --short HEAD")?.output
+ },
+ branch: () => exec("git rev-parse --abbrev-ref HEAD")?.output,
+ dirty: () => exec("git diff --quiet")?.status !== 0,
+ lookup: (lookups: GitLookup[]): Version => {
+ for (const lookup of lookups) {
+ switch (lookup) {
+ case "tag":
+ return {
+ location: "git-tag",
+ tag: git.tag()
+ };
+ case "commit":
+ return {
+ location: "git-commit",
+ commit: {
+ long: git.commit.long() ?? "unknown",
+ short: git.commit.short() ?? "unknown"
+ }
+ };
+ case "branch":
+ return {
+ location: "git-branch",
+ branch: git.branch() ?? "unknown"
+ };
+ case "dirty":
+ return {
+ location: "git-dirty",
+ dirty: git.dirty()
+ };
+ case "date":
+ return {
+ location: "git-date",
+ date: {
+ actual: new Date(git.date() ?? "unknown"),
+ human: git.date() ?? "unknown"
+ }
+ };
+
+ default:
+ throw new Error(`Unknown git lookup: ${lookup}`);
+ }
+ }
+ }
+};
diff --git a/demo/src/lib/version/index.ts b/demo/src/lib/version/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/demo/src/lib/version/types.ts b/demo/src/lib/version/types.ts
new file mode 100644
index 0000000..1b88519
--- /dev/null
+++ b/demo/src/lib/version/types.ts
@@ -0,0 +1,16 @@
+import type { VersionLocation } from "./vite-plugin-version";
+
+export type Version = {
+ location: VersionLocation;
+ tag?: string;
+ commit?: {
+ long: string;
+ short: string;
+ };
+ dirty?: boolean;
+ branch?: string;
+ date?: {
+ actual: Date;
+ human: string;
+ };
+};
diff --git a/demo/src/lib/version/version.ts b/demo/src/lib/version/version.ts
new file mode 100644
index 0000000..6baa9a0
--- /dev/null
+++ b/demo/src/lib/version/version.ts
@@ -0,0 +1,47 @@
+import fs from "node:fs";
+import { git } from "./git";
+import type { Version } from "./types";
+import type { VersionConfig } from "./vite-plugin-version";
+
+export const ago = (date: Date, locale = "en"): string => {
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
+
+ const now = new Date();
+ const diff = (date.getTime() - now.getTime()) / 1000;
+
+ const units: [Intl.RelativeTimeFormatUnit, number][] = [
+ ["year", diff / (60 * 60 * 24 * 365)],
+ ["month", diff / (60 * 60 * 24 * 30)],
+ ["week", diff / (60 * 60 * 24 * 7)],
+ ["day", diff / (60 * 60 * 24)],
+ ["hour", diff / (60 * 60)],
+ ["minute", diff / 60],
+ ["second", diff]
+ ];
+
+ for (const [unit, value] of units) {
+ const rounded = Math.round(value);
+ if (Math.abs(rounded) >= 1) {
+ return rtf.format(rounded, unit);
+ }
+ }
+
+ return rtf.format(0, "second");
+};
+
+export const setVersion = (config?: VersionConfig): Version => {
+ for (const location of config?.locations ?? ["git-tag", "package.json"]) {
+ if (location === "git-tag") {
+ return {
+ location: "git-tag",
+ tag: git.tag()
+ };
+ } else if (location === "package.json") {
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
+ return {
+ location: "package.json",
+ tag: pkg.version
+ };
+ }
+ }
+};
diff --git a/demo/src/lib/version/vite-plugin-version.ts b/demo/src/lib/version/vite-plugin-version.ts
new file mode 100644
index 0000000..1b3677c
--- /dev/null
+++ b/demo/src/lib/version/vite-plugin-version.ts
@@ -0,0 +1,62 @@
+import { setVersion } from "./version";
+
+export type VersionLocation = "git-tag" | "git-commit" | "git-branch" | "git-dirty" | "git-date" | "package.json";
+
+export interface VersionConfig {
+ debug?: boolean;
+ /**
+ * The locations to check for version information.
+ *
+ * The order is important, the first location that is found will be used
+ * otherwise the next (fallback) location will be used.
+ *
+ * @default ["git-tag", "package.json"]
+ */
+ locations?: VersionLocation[];
+}
+
+/**
+ * This plugin is used to set the version of the application.
+ *
+ * @param {VersionConfig} pluginConfig The plugin configuration.
+ *
+ * @returns {any} The plugin option.
+ *
+ * @example
+ * ```ts
+ * import { versionPlugin } from "vite-plugin-version";
+ *
+ * export default defineConfig({
+ * plugins: [versionPlugin()]
+ * });
+ * ```
+ */
+export const versionPlugin = (pluginConfig?: VersionConfig): any => {
+ const versionData = setVersion(pluginConfig);
+ const versionString = JSON.stringify(versionData);
+
+ return {
+ name: "version-plugin",
+ config(config: any) {
+ if (pluginConfig?.debug) {
+ console.log("versionPlugin.config() versionData:", versionData);
+ }
+
+ // Merge with existing define config:
+ config.define = {
+ ...config.define,
+ // This will replace __VERSION__ in the code at build time:
+ __VERSION__: versionString
+ };
+
+ // For import.meta.env.VITE_VERSION access:
+ process.env.VITE_VERSION = versionString;
+
+ if (pluginConfig?.debug) {
+ console.log("versionPlugin.config() config.define:", config.define);
+ }
+
+ return config;
+ }
+ };
+};
diff --git a/demo/src/main.js b/demo/src/main.js
deleted file mode 100644
index ba7e77c..0000000
--- a/demo/src/main.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { mount } from "svelte";
-import "./app.css";
-import App from "./app.svelte";
-const app = mount(App, {
- target: document.getElementById("app")
-});
-export default app;
-//# sourceMappingURL=main.js.map
\ No newline at end of file
diff --git a/demo/src/main.js.map b/demo/src/main.js.map
deleted file mode 100644
index 7fa5f21..0000000
--- a/demo/src/main.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"main.js","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAC/B,OAAO,WAAW,CAAC;AACnB,OAAO,GAAG,MAAM,cAAc,CAAC;AAE/B,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,EAAE;IACrB,MAAM,EAAE,QAAQ,CAAC,cAAc,CAAC,KAAK,CAAE;CACxC,CAAC,CAAC;AAEH,eAAe,GAAG,CAAC"}
\ No newline at end of file
diff --git a/demo/src/test-setup.js b/demo/src/test-setup.js
deleted file mode 100644
index e7e0b07..0000000
--- a/demo/src/test-setup.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import "@testing-library/jest-dom";
-import { vi } from "vitest";
-// Mock URL.createObjectURL and URL.revokeObjectURL for tests
-globalThis.URL.createObjectURL = vi.fn(() => "blob:test-url");
-globalThis.URL.revokeObjectURL = vi.fn();
-// Mock console methods to reduce test noise
-globalThis.console = Object.assign(Object.assign({}, console), { warn: vi.fn(), error: vi.fn() });
-// Setup DOM environment
-Object.defineProperty(window, "location", {
- value: {
- href: "http://localhost:3000"
- },
- writable: true
-});
-// Create a mock component class for testing
-export class MockSvelteComponent {
- constructor({ target, props }) {
- this.target = target;
- this.props = props;
- this.render();
- }
- render() {
- if (this.target) {
- this.target.innerHTML = "
Mock Dynamic Component
";
- }
- }
- $destroy() {
- if (this.target) {
- this.target.innerHTML = "";
- }
- }
-}
-//# sourceMappingURL=test-setup.js.map
\ No newline at end of file
diff --git a/demo/src/test-setup.js.map b/demo/src/test-setup.js.map
deleted file mode 100644
index 039759f..0000000
--- a/demo/src/test-setup.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"test-setup.js","sourceRoot":"","sources":["test-setup.ts"],"names":[],"mappings":"AAAA,OAAO,2BAA2B,CAAC;AACnC,OAAO,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAE5B,6DAA6D;AAC7D,UAAU,CAAC,GAAG,CAAC,eAAe,GAAG,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,eAAe,CAAC,CAAC;AAC9D,UAAU,CAAC,GAAG,CAAC,eAAe,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;AAEzC,4CAA4C;AAC5C,UAAU,CAAC,OAAO,mCACb,OAAO,KACV,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE,EACb,KAAK,EAAE,EAAE,CAAC,EAAE,EAAE,GACf,CAAC;AAEF,wBAAwB;AACxB,MAAM,CAAC,cAAc,CAAC,MAAM,EAAE,UAAU,EAAE;IACxC,KAAK,EAAE;QACL,IAAI,EAAE,uBAAuB;KAC9B;IACD,QAAQ,EAAE,IAAI;CACf,CAAC,CAAC;AAEH,4CAA4C;AAC5C,MAAM,OAAO,mBAAmB;IAI9B,YAAY,EAAE,MAAM,EAAE,KAAK,EAAwC;QACjE,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,EAAE,CAAC;IAChB,CAAC;IAED,MAAM;QACJ,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAChB,IAAI,CAAC,MAAM,CAAC,SAAS,GAAG,mCAAmC,CAAC;QAC9D,CAAC;IACH,CAAC;IAED,QAAQ;QACN,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAChB,IAAI,CAAC,MAAM,CAAC,SAAS,GAAG,EAAE,CAAC;QAC7B,CAAC;IACH,CAAC;CACF"}
\ No newline at end of file
diff --git a/demo/src/test-setup.ts b/demo/src/test-setup.ts
index fabd443..6710c5b 100644
--- a/demo/src/test-setup.ts
+++ b/demo/src/test-setup.ts
@@ -1,18 +1,24 @@
import "@testing-library/jest-dom";
import { vi } from "vitest";
-// Mock URL.createObjectURL and URL.revokeObjectURL for tests
+/**
+ * Mock URL.createObjectURL and URL.revokeObjectURL for tests.
+ */
globalThis.URL.createObjectURL = vi.fn(() => "blob:test-url");
globalThis.URL.revokeObjectURL = vi.fn();
-// Mock console methods to reduce test noise
+/**
+ * Mock console methods to reduce test noise.
+ */
globalThis.console = {
...console,
warn: vi.fn(),
error: vi.fn()
};
-// Setup DOM environment
+/**
+ * Setup DOM environment.
+ */
Object.defineProperty(window, "location", {
value: {
href: "http://localhost:3000"
@@ -20,7 +26,9 @@ Object.defineProperty(window, "location", {
writable: true
});
-// Create a mock component class for testing
+/**
+ * Create a mock component class for testing.
+ */
export class MockSvelteComponent {
public target: HTMLElement;
public props: any;
diff --git a/demo/src/vite-env.d.ts b/demo/src/vite-env.d.ts
index e69c825..ddd6bdf 100644
--- a/demo/src/vite-env.d.ts
+++ b/demo/src/vite-env.d.ts
@@ -1,11 +1,5 @@
-///
///
-interface ViteTypeOptions {
- // By adding this line, you can make the type of ImportMetaEnv strict
- // to disallow unknown keys.
- // strictImportMetaEnv: unknown
-}
interface ImportMetaEnv {
- readonly CURRENT_VERSION: Version;
+ readonly VITE_VERSION: string; // JSON stringified Version object from my plugin.
}
diff --git a/demo/tsconfig.json b/demo/tsconfig.json
index 2652022..0c91dd4 100644
--- a/demo/tsconfig.json
+++ b/demo/tsconfig.json
@@ -2,16 +2,8 @@
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
- "target": "es2023",
- /**
- Svelte Preprocess cannot figure out whether you have a value or a type, so tell TypeScript
- to enforce using `import type` instead of `import` for Types.
- */
+ "target": "es2022",
"verbatimModuleSyntax": true,
- /**
- To have warnings/errors of the Svelte compiler at the correct position,
- enable source maps by default.
- */
"sourceMap": true,
"strict": true,
"strictNullChecks": false,
@@ -19,11 +11,11 @@
"skipLibCheck": true,
"paths": {
"@mateothegreat/dynamic-component-engine": ["../src/index.ts"],
+ "@mateothegreat/dynamic-component-engine/compiler": ["../src/compiler/index.ts"],
"$lib": ["./src/lib"],
"$lib/*": ["./src/lib/*"],
"$components/*": ["./src/lib/components/ui/*"]
}
},
- "include": ["./src/**/*.ts", "./src/**/*.js", "./src/**/*.svelte"],
- "exclude": ["src/lib/components/ui"]
+ "include": ["./src/**/*.ts", "./src/**/*.svelte", "./src/**/*.svelte.ts"]
}
diff --git a/demo/vercel.json b/demo/vercel.json
new file mode 100644
index 0000000..066f3ec
--- /dev/null
+++ b/demo/vercel.json
@@ -0,0 +1,6 @@
+{
+ "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }],
+ "github": {
+ "enabled": true
+ }
+}
diff --git a/demo/vite.config.ts b/demo/vite.config.ts
index 9db975d..c2067cd 100644
--- a/demo/vite.config.ts
+++ b/demo/vite.config.ts
@@ -4,10 +4,14 @@ import tailwindcss from "@tailwindcss/vite";
import path from "path";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
-import versionPlugin from "../vite-plugin-version";
+import { versionPlugin } from "./src/lib/version/vite-plugin-version";
export default defineConfig({
+ logLevel: "info",
plugins: [
+ versionPlugin({
+ locations: ["package.json"]
+ }),
tsconfigPaths(),
svelte(),
svelteInspector({
@@ -15,20 +19,11 @@ export default defineConfig({
showToggleButton: "always",
toggleButtonPos: "bottom-left"
}),
- versionPlugin(),
- /**
- * This sets it so that when you change a file, the whole page will reload
- * rather than hmr only reloading the changes.
- */
- // {
- // name: "full-reload",
- // handleHotUpdate({ server }) {
- // server.ws.send({ type: "full-reload" });
- // return [];
- // }
- // },
tailwindcss()
],
+ build: {
+ reportCompressedSize: true
+ },
resolve: {
alias: {
"@mateothegreat/dynamic-component-engine": path.resolve(__dirname, "../src/index.ts"),
diff --git a/docs/reactivity.md b/docs/reactivity.md
new file mode 100644
index 0000000..6681535
--- /dev/null
+++ b/docs/reactivity.md
@@ -0,0 +1,134 @@
+# Rune-Based Reactive State Sharing Problem Statement
+
+The problem arises when Svelte 5 runes create reactive references that lose their reactivity when serialized across compilation boundaries.
+
+## The Core Issue
+
+When we have `let count = $state(123)` in our parent component, we're creating a reactive reference.
+
+However, when we try to pass this to a dynamically compiled component, several things break the reactivity chain:
+
+### 1. **Value** vs **Reference** Passing
+
+Parent component:
+
+```ts
+let count = $state(123);
+```
+
+What you're actually passing to the dynamic component:
+
+```ts
+const props = { count }; // This passes the VALUE (123), not the reactive reference
+```
+
+The `$state(123)` creates a reactive proxy/reference, but when you put count in the props object, you're passing the current value (`123`), not the reactive reference itself.
+
+### 2. Compilation Boundary Isolation
+
+Parent component (compiled at build time):
+
+```ts
+let count = $state(123);
+```
+
+Dynamic component (compiled at runtime):
+
+```ts
+let { count } = $props(); // This creates a NEW local variable, disconnected from parent
+```
+
+Each Svelte compilation creates its own reactive scope. The dynamically compiled component has no knowledge of the parent's reactive system - **it's essentially a _separate_ "universe" of reactivity**.
+
+### 3. Svelte's Internal Reactivity System
+
+Svelte 5 runes work through:
+
++ Reactive proxies that track dependencies.
++ Effect scheduling that runs when dependencies change.
++ Component boundaries that isolate reactive scopes
+
+When you pass count as a prop, the dynamic component receives a **snapshot** _value_, **not** a reactive subscription.
+
+### 4. The Mount/Unmount API Limitation
+
+```ts
+const component = mount(DynamicComponent, { target, props: { count: 123 } });
+```
+
+Svelte's `mount()` API expects static props at mount time. It doesn't have a built-in mechanism to pass ongoing reactive references that update the component after mounting.
+
+### Why The Original Approach Didn't Work
+
+Parent component:
+
+```ts
+let count = $state(123);
+```
+
+Dynamic component template:
+
+```ts
+let { count = $bindable(0) } = $props();
+```
+
+This creates two separate reactive variables with the same name but no connection between them.
+
+The `$bindable()` in the dynamic component creates its own reactive reference, completely isolated from the parent's `$state()`.
+
+### The Real Solution Would Require
+
+1) Passing reactive references (not values) across compilation boundaries.
+2) Shared reactive context that both components can access.
+3) Live prop updates after component mounting.
+4) Cross-compilation reactive dependency tracking.
+
+This is why the `SharedRuneStore` solution works (see [/src/shared-rune-store.ts](../src/shared-rune-store.ts)) - it creates a shared reactive context that both the parent and dynamic components can subscribe to, bypassing the compilation boundary limitations.
+
+## Potential Solutions
+
+### Solution 1: Reactive Context Injection
+
+Modify the factory function to accept reactive context objects that get injected into the component's compilation scope.
+
+Instead of passing static values, pass rune references directly through the compilation process.
+
+**Approach**:
+
++ Extend `ComponentCompiler.render()` to accept a reactiveContext parameter containing rune objects.
++ Modify transformCompiledCode() to inject these runes as imports/globals in the compiled component.
++ The dynamic component accesses parent runes directly via injected context.
+
+### Solution 2: Rune Reference Passing
+
+Create a mechanism to pass actual rune references (not their values) through the mount system by
+
+serializing rune getters/setters and reconstructing them in the dynamic component.
+
+**Approach**:
+
++ Extend the factory function to accept rune descriptors `({ get: () => value, set: (v) => {...} })`.
++ Transform the compiled code to wire these descriptors to local rune state.
++ Dynamic components get direct access to parent runes through the descriptor interface.
+
+### Solution 3: Shared Rune Store
+
+Implement a global rune store that both parent and dynamic components can subscribe to, using Svelte 5's $state() with a singleton pattern.
+
+**Approach**:
+
++ Create a `SharedRuneStore` class with `$state()` values.
++ Parent components register their runes in the store.
++ Dynamic components access the same rune references from the store.
++ No wrapper/proxy - direct rune access through shared state container.
+
+### Solution 4: Compilation-Time Rune Binding
+
+Modify the source transformation to replace rune references in the dynamic component with bindings to externally provided rune objects.
+
+**Approach**:
+
++ Parse the dynamic component source for rune usage patterns.
++ Replace `let { count = $bindable(0) } = $props()` with direct external rune bindings.
++ Inject the actual parent runes as module-level imports during compilation.
++ Component uses parent runes as if they were its own.
\ No newline at end of file
diff --git a/docs/readme.md b/docs/readme.md
index afe6044..b030f6b 100644
--- a/docs/readme.md
+++ b/docs/readme.md
@@ -2,6 +2,9 @@
A powerful, secure, and flexible runtime component compiler for Svelte 5+ applications.
+> [!NOTE]
+> Live demo is available at [https://dynamic-component-engine.matthewdavis.io](https://dynamic-component-engine.matthewdavis.io)!
+
## ✨ Features
- **Runtime Component Compilation**: Transform Svelte component strings into fully functional components on the fly.
@@ -86,7 +89,30 @@ Here's an example of how to use the dynamic component engine to render a compone
You must first compile your Svelte component(s) down to a string and serve it to the client (http endpoint, websockets, etc.) then you can use the `load` and `render` functions to dynamically render the component(s) in the browser.
-### `esbuild-svelte`
+### Using the [`compile-components`](../demo/bin/compile-components) script
+
+I've provided an easy button to compile your components down to a single file that can be served to the client.
+
+Simply run `npm run compile` and it will compile all the components in the `shared-components` directory down to a single files that can be served to the client.
+Your output will look like this:
+
+```sh
+➜ npm run compile
+
+> demo@0.0.1 compile
+> node bin/compile-components
+
+Discovering components via ./shared-components/**/entry.ts
++ Discovered component entrypoint: shared-components/simple/entry.ts
+
+Compiling (1) component...
+
+ public/entry.js 2.7kb
+
+⚡ Done in 191ms
+```
+
+### Manually using `esbuild-svelte`
```typescript
import esbuild from "esbuild";
@@ -116,28 +142,12 @@ async function bundleSvelte(entry) {
return build.outputFiles;
}
-bundleSvelte(["./src/components/entry.ts"]);
-```
-
-Now you're ready to compile your svelte component(s) down to an esm module:
-
-```bash
-node build.js
-```
-
-Should show something like this in your terminal:
-
-```shell
-$ node build.js
-
- public/entry.js 1.2kb
-
-⚡ Done in 156ms
+bundleSvelte(["./shared-components/simple/entry.ts"]);
```
### Output
-After running `node build.js` the output will be a single file in the `public` directory and will look like this:
+After running `npm run compile` the output will be a single file in the `public` directory and will look like this:
```js
const compiledComponentSource = `
diff --git a/out/entry.js b/out/entry.js
new file mode 100644
index 0000000..0c0345c
--- /dev/null
+++ b/out/entry.js
@@ -0,0 +1,107 @@
+"use strict";
+(() => {
+ var __create = Object.create;
+ var __defProp = Object.defineProperty;
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
+ var __getOwnPropNames = Object.getOwnPropertyNames;
+ var __getProtoOf = Object.getPrototypeOf;
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
+ }) : x)(function(x) {
+ if (typeof require !== "undefined") return require.apply(this, arguments);
+ throw Error('Dynamic require of "' + x + '" is not supported');
+ });
+ var __copyProps = (to, from, except, desc) => {
+ if (from && typeof from === "object" || typeof from === "function") {
+ for (let key of __getOwnPropNames(from))
+ if (!__hasOwnProp.call(to, key) && key !== except)
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
+ }
+ return to;
+ };
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
+ // If the importer is in node compatibility mode or this is not an ESM
+ // file that has been converted to a CommonJS file using a Babel-
+ // compatible transform (i.e. "__esModule" has not been set), then set
+ // "default" to the CommonJS "module.exports" for node compatibility.
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
+ mod
+ ));
+
+ // demo/shared-components/simple/entry.ts
+ var import_svelte = __require("svelte");
+
+ // demo/shared-components/simple/simple.svelte
+ var import_disclose_version = __require("svelte/internal/disclose-version");
+ var $ = __toESM(__require("svelte/internal/client"));
+ var on_click = (_, testState) => $.update(testState);
+ var root = $.from_html(
+ `
+
+
+
+
Simple.svelte
+
This is a simple component that is rendered on the receiving side. See the source code for more details.