@zotezica/simple-front 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # @zotezica/simple-front
2
+
3
+ A minimal JS library for server-rendered apps with interactive islands.
4
+
5
+ ## Philosophy
6
+
7
+ Modern full-stack frameworks (Next.js, SvelteKit, Remix) own everything — routing, data fetching, rendering — and couple your backend to their conventions. This is too much when you have a real backend (Hono, Express, Rails) that renders HTML and you just need a little client-side interactivity.
8
+
9
+ The insight: **the only things you actually need from a framework are:**
10
+ 1. Smooth navigation between server-rendered pages (no full-page blink)
11
+ 2. A way to mount interactive components onto server-rendered DOM nodes
12
+
13
+ That's it. Everything else stays on the server.
14
+
15
+ **Core principles:**
16
+ - Server renders all read-only data — no spinners, no client-side fetches for page content
17
+ - Islands own only interactivity: forms, toggles, drawers
18
+ - CSP `script-src 'self'` compliant — no inline event handlers, no eval
19
+ - No opinions about your backend or build tool
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install @zotezica/simple-front
25
+ ```
26
+
27
+ Svelte and React are optional peer dependencies — install whichever you use.
28
+
29
+ ## Usage
30
+
31
+ ```ts
32
+ // Svelte
33
+ import { island, start, slideDownFadeIn } from "@zotezica/simple-front/svelte";
34
+
35
+ // React
36
+ import { island, start, slideDownFadeIn } from "@zotezica/simple-front/react";
37
+ ```
38
+
39
+ Register islands and call `start()`:
40
+
41
+ ```ts
42
+ const animate = slideDownFadeIn();
43
+
44
+ island("search-island", () => import("./islands/Search.svelte"), { animate });
45
+
46
+ island("product-island", () => import("./islands/Product.svelte"), {
47
+ props: (el) => ({ productId: el.dataset.productId }),
48
+ animate,
49
+ });
50
+
51
+ start();
52
+ ```
53
+
54
+ The server renders mount points as empty divs with `data-*` props:
55
+
56
+ ```html
57
+ <div id="product-island" data-product-id="abc123"></div>
58
+ ```
59
+
60
+ `simple-front` finds them by ID, reads props from `data-*` attributes, and mounts the component.
61
+
62
+ ## API
63
+
64
+ ### `island(id, importer, options?)`
65
+
66
+ Registers an island.
67
+
68
+ | Option | Type | Description |
69
+ |--------|------|-------------|
70
+ | `props` | `(el: HTMLElement) => Record<string, unknown>` | Reads props from the element before mounting |
71
+ | `animate` | `IslandAnimation` | Animation to run when the island mounts |
72
+
73
+ ### `start()`
74
+
75
+ Attaches the click interceptor, `popstate`, and `app:navigate` listeners, then mounts all registered islands found in the current DOM.
76
+
77
+ ### `navigate(url, options?)`
78
+
79
+ Programmatically navigate to a URL. Options: `{ replace?: boolean }`.
80
+
81
+ ### `app:navigate` event
82
+
83
+ Islands trigger navigation by dispatching a custom event — no need to import `navigate`:
84
+
85
+ ```ts
86
+ window.dispatchEvent(new CustomEvent("app:navigate", {
87
+ detail: { url: "/products/abc123" }
88
+ }));
89
+ ```
90
+
91
+ ## Animations
92
+
93
+ Built-in animations, all accepting `{ duration?, easing? }`:
94
+
95
+ ```ts
96
+ import { slideDown, fadeIn, slideDownFadeIn } from "@zotezica/simple-front/svelte";
97
+
98
+ island("my-island", () => import("./My.svelte"), {
99
+ animate: slideDownFadeIn({ duration: 300 }),
100
+ });
101
+ ```
102
+
103
+ | Animation | Description |
104
+ |-----------|-------------|
105
+ | `slideDown()` | Animates height from 0 to natural height |
106
+ | `fadeIn()` | Animates opacity from 0 to 1 |
107
+ | `slideDownFadeIn()` | Slide, then fade |
108
+
109
+ You can also pass any custom animation:
110
+
111
+ ```ts
112
+ island("my-island", () => import("./My.svelte"), {
113
+ animate: {
114
+ prepare: (el) => { el.style.opacity = "0"; },
115
+ run: (el) => el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300 }).finished,
116
+ },
117
+ });
118
+ ```
119
+
120
+ ## Navigation
121
+
122
+ `simple-front` intercepts same-origin link clicks, fetches the new page, diffs the DOM with morphdom, and updates the URL — no full-page reload. View Transitions API is used when available.
123
+
124
+ The `X-Navigation: 1` header is sent on every fetch, letting the server skip the layout if it wants to return a partial response.
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@zotezica/simple-front",
3
+ "version": "0.1.0",
4
+ "description": "Minimal JS library for server-rendered apps with interactive islands",
5
+ "type": "module",
6
+ "author": "Zica",
7
+ "license": "MIT",
8
+ "packageManager": "pnpm@10.29.3",
9
+ "engines": {
10
+ "node": ">=22.0.0"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "scripts": {
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/core.ts",
21
+ "default": "./src/core.ts"
22
+ },
23
+ "./svelte": {
24
+ "types": "./src/svelte.ts",
25
+ "default": "./src/svelte.ts"
26
+ },
27
+ "./react": {
28
+ "types": "./src/react.ts",
29
+ "default": "./src/react.ts"
30
+ }
31
+ },
32
+ "files": [
33
+ "src"
34
+ ],
35
+ "peerDependencies": {
36
+ "react": ">=18.0.0",
37
+ "react-dom": ">=18.0.0",
38
+ "svelte": ">=5.0.0"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "svelte": {
42
+ "optional": true
43
+ },
44
+ "react": {
45
+ "optional": true
46
+ },
47
+ "react-dom": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "dependencies": {
52
+ "morphdom": "^2.7.4"
53
+ },
54
+ "devDependencies": {
55
+ "@types/react": "^19.0.0",
56
+ "@types/react-dom": "^19.0.0",
57
+ "react": "^19.0.0",
58
+ "react-dom": "^19.0.0",
59
+ "svelte": "^5.55.8",
60
+ "typescript": "^5.8.3"
61
+ }
62
+ }
@@ -0,0 +1,57 @@
1
+ import type { IslandAnimation } from "./types.js";
2
+
3
+ type AnimationOptions = {
4
+ duration?: number;
5
+ easing?: string;
6
+ };
7
+
8
+ export function slideDown({ duration = 300, easing = "ease" }: AnimationOptions = {}): IslandAnimation {
9
+ return {
10
+ run: (el) => {
11
+ el.style.height = "0px";
12
+ el.style.overflow = "hidden";
13
+
14
+ return new Promise((resolve) => {
15
+ requestAnimationFrame(() => {
16
+ const targetHeight = el.scrollHeight;
17
+ const anim = el.animate(
18
+ [{ height: "0px" }, { height: `${targetHeight}px` }],
19
+ { duration, easing },
20
+ );
21
+ anim.onfinish = () => {
22
+ el.style.height = "";
23
+ el.style.overflow = "";
24
+ resolve();
25
+ };
26
+ });
27
+ });
28
+ },
29
+ };
30
+ }
31
+
32
+ export function fadeIn({ duration = 300, easing = "ease-out" }: AnimationOptions = {}): IslandAnimation {
33
+ return {
34
+ prepare: (el) => { el.style.opacity = "0"; },
35
+ run: (el) => {
36
+ return new Promise((resolve) => {
37
+ requestAnimationFrame(() => {
38
+ const anim = el.animate([{ opacity: "0" }, { opacity: "1" }], { duration, easing });
39
+ anim.onfinish = () => {
40
+ el.style.opacity = "";
41
+ resolve();
42
+ };
43
+ });
44
+ });
45
+ },
46
+ };
47
+ }
48
+
49
+ export function slideDownFadeIn({ duration = 300, easing = "ease" }: AnimationOptions = {}): IslandAnimation {
50
+ return {
51
+ prepare: (el) => { el.style.opacity = "0"; },
52
+ run: async (el) => {
53
+ await slideDown({ duration, easing }).run(el);
54
+ await fadeIn({ duration: Math.round(duration * 0.6), easing: "ease-out" }).run(el);
55
+ },
56
+ };
57
+ }
package/src/core.ts ADDED
@@ -0,0 +1,98 @@
1
+ import morphdom from "morphdom";
2
+ import type { AppConfig, IslandOptions, MountFn } from "./types.js";
3
+ export { slideDown, fadeIn, slideDownFadeIn } from "./animations.js";
4
+ export type { AnimateFn, PropsFactory, IslandAnimation, IslandOptions, MountFn, AppConfig, NavigateDetail } from "./types.js";
5
+
6
+ type IslandEntry = {
7
+ importer: () => Promise<{ default: unknown }>;
8
+ options: IslandOptions;
9
+ };
10
+
11
+ const parser = new DOMParser();
12
+
13
+ export function createApp(config: AppConfig) {
14
+ const registry = new Map<string, IslandEntry>();
15
+ const mounted = new WeakSet<Element>();
16
+
17
+ async function navigate(url: string, { replace = false }: { replace?: boolean } = {}): Promise<void> {
18
+ const res = await fetch(url, { headers: { "X-Navigation": "1" } });
19
+
20
+ if (!res.ok || res.redirected) {
21
+ location.assign(res.url || url);
22
+ return;
23
+ }
24
+
25
+ const html = await res.text();
26
+ const doc = parser.parseFromString(html, "text/html");
27
+
28
+ const transition = () => {
29
+ morphdom(document.body, doc.body, { childrenOnly: true });
30
+ document.title = doc.title;
31
+ if (replace) {
32
+ history.replaceState({}, "", url);
33
+ } else {
34
+ history.pushState({}, "", url);
35
+ }
36
+ mountIslands();
37
+ };
38
+
39
+ if (document.startViewTransition) {
40
+ document.startViewTransition(transition);
41
+ } else {
42
+ transition();
43
+ }
44
+ }
45
+
46
+ function mountIslands(): void {
47
+ for (const [id, entry] of registry) {
48
+ const el = document.getElementById(id);
49
+ if (!el) continue;
50
+ if (mounted.has(el)) continue;
51
+ mounted.add(el);
52
+ mountIsland(el, entry, config.mount);
53
+ }
54
+ }
55
+
56
+ function island(
57
+ id: string,
58
+ importer: () => Promise<{ default: unknown }>,
59
+ options: IslandOptions = {},
60
+ ): void {
61
+ registry.set(id, { importer, options });
62
+ }
63
+
64
+ function start(): void {
65
+ document.addEventListener("click", (e) => {
66
+ const a = (e.target as Element).closest("a[href]") as HTMLAnchorElement | null;
67
+ if (!a) return;
68
+ if (a.origin !== location.origin) return;
69
+ if (a.hasAttribute("download") || a.target === "_blank") return;
70
+ e.preventDefault();
71
+ if (a.href === location.href) return;
72
+ navigate(a.href);
73
+ });
74
+
75
+ window.addEventListener("popstate", () => navigate(location.href, { replace: true }));
76
+
77
+ window.addEventListener("app:navigate", (e) => {
78
+ const { url, replace } = (e as CustomEvent<{ url: string; replace?: boolean }>).detail;
79
+ navigate(url, { replace });
80
+ });
81
+
82
+ mountIslands();
83
+ }
84
+
85
+ return { island, start, navigate };
86
+ }
87
+
88
+ function mountIsland(el: HTMLElement, entry: IslandEntry, mountFn: MountFn): void {
89
+ const props = entry.options.props ? entry.options.props(el) : {};
90
+ const animate = entry.options.animate;
91
+
92
+ if (animate?.prepare) animate.prepare(el);
93
+
94
+ entry.importer().then(({ default: Component }) => {
95
+ mountFn(Component, el, props);
96
+ if (animate) animate.run(el);
97
+ });
98
+ }
package/src/react.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { createElement } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { createApp } from "./core.js";
4
+ export { slideDown, fadeIn, slideDownFadeIn } from "./animations.js";
5
+ export type { AnimateFn, PropsFactory, IslandAnimation, IslandOptions } from "./types.js";
6
+
7
+ const { island, start, navigate } = createApp({
8
+ mount: (Component, target, props) =>
9
+ createRoot(target).render(createElement(Component as Parameters<typeof createElement>[0], props)),
10
+ });
11
+
12
+ export { island, start, navigate };
package/src/svelte.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { mount } from "svelte";
2
+ import { createApp } from "./core.js";
3
+ export { slideDown, fadeIn, slideDownFadeIn } from "./animations.js";
4
+ export type { AnimateFn, PropsFactory, IslandAnimation, IslandOptions } from "./types.js";
5
+
6
+ const { island, start, navigate } = createApp({
7
+ mount: (Component, target, props) =>
8
+ mount(Component as Parameters<typeof mount>[0], { target, props }),
9
+ });
10
+
11
+ export { island, start, navigate };
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type AnimateFn = (el: HTMLElement) => void | Promise<void>;
2
+
3
+ export type PropsFactory<T extends Record<string, unknown> = Record<string, unknown>> = (
4
+ el: HTMLElement,
5
+ ) => T;
6
+
7
+ export type IslandAnimation = {
8
+ prepare?: (el: HTMLElement) => void;
9
+ run: AnimateFn;
10
+ };
11
+
12
+ export type IslandOptions<T extends Record<string, unknown> = Record<string, unknown>> = {
13
+ props?: PropsFactory<T>;
14
+ animate?: IslandAnimation;
15
+ };
16
+
17
+ export type MountFn = (
18
+ Component: unknown,
19
+ target: HTMLElement,
20
+ props: Record<string, unknown>,
21
+ ) => void;
22
+
23
+ export type AppConfig = {
24
+ mount: MountFn;
25
+ };
26
+
27
+ export type NavigateDetail = {
28
+ url: string;
29
+ replace?: boolean;
30
+ };