Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Context API helpers via `createContext`, `provideContext`, and `useContext`
- Runtime `createProvider()` support for provider-backed component trees
- Typed intrinsic element props for `h()` plus compile-time JSX prop coverage
- Dev-mode HMR export detection and component swap hooks for Vite

### Changed
- Demo app now uses context for theme state instead of local prop wiring
- Runtime package now exports `jsx-types` declarations for downstream tooling
- Exported runtime components can now be hot-swapped in place during local development

## [0.3.0] - 2026-03-21

### Added
Expand Down
28 changes: 25 additions & 3 deletions apps/demo/src/feed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createMutation, createQuery, createSignal, createStore, h } from "@sarthakdev143/shadejs";
import { createMutation, createQuery, createSignal, createStore, h, useContext } from "@sarthakdev143/shadejs";

import { addPost, getPosts } from "./posts.server";
import { ThemeContext } from "./theme";

const [count, setCount] = createSignal(0);
const composer = createStore({
Expand All @@ -11,6 +12,24 @@ const { mutate: submitPost, pending: isSubmitting } = createMutation(addPost, {
invalidates: ["posts"]
});

function ThemeControls() {
const { theme, toggleTheme } = useContext(ThemeContext);

return h(
"div",
{ className: "theme-controls" },
h("span", { className: "theme-chip" }, () => `Theme: ${theme()}`),
h(
"button",
{
className: "theme-button",
onClick: toggleTheme
},
() => (theme() === "dark" ? "Switch to light" : "Switch to dark")
)
);
}

function renderPosts() {
const state = posts();

Expand Down Expand Up @@ -48,9 +67,11 @@ async function handleAddPost(): Promise<void> {
}

export function Feed() {
const { theme } = useContext(ThemeContext);

return h(
"main",
{ className: "shell" },
{ className: "shell", "data-theme": theme },
h(
"section",
{ className: "hero panel" },
Expand All @@ -61,6 +82,7 @@ export function Feed() {
{ className: "lede" },
"The counter uses signals, the composer uses createStore, and the feed uses compiler-generated RPC stubs."
),
h(ThemeControls, null),
h(
"div",
{ className: "counter-card" },
Expand All @@ -80,7 +102,7 @@ export function Feed() {
"section",
{ className: "panel composer" },
h("div", { className: "panel-head" }, h("h2", null, "Create a server post")),
h("label", { className: "field-label", for: "post-title" }, "Draft title"),
h("label", { className: "field-label", htmlFor: "post-title" }, "Draft title"),
h("input", {
className: "post-input",
id: "post-title",
Expand Down
20 changes: 18 additions & 2 deletions apps/demo/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { mount } from "@sarthakdev143/shadejs";
import { createSignal, h, mount } from "@sarthakdev143/shadejs";

import { Feed } from "./feed";
import "./styles.css";
import { ThemeProvider, type ThemeMode } from "./theme";

const app = document.querySelector("#app");

if (!(app instanceof Element)) {
throw new Error("ShadeJS demo root element was not found.");
}

mount(Feed, app);
function App() {
const [theme, setTheme] = createSignal<ThemeMode>("dark");

return h(
ThemeProvider,
{
value: {
theme,
toggleTheme: () => setTheme((current) => (current === "dark" ? "light" : "dark"))
}
},
h(Feed, null)
);
}

mount(App, app);
73 changes: 73 additions & 0 deletions apps/demo/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ input {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
color: #f4efe7;
}

.panel {
Expand Down Expand Up @@ -95,6 +96,43 @@ input {
padding: 1.5rem;
}

.theme-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 1.5rem;
}

.theme-chip {
display: inline-flex;
align-items: center;
min-height: 2.6rem;
padding: 0.45rem 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.78rem;
color: rgba(244, 239, 231, 0.82);
background: rgba(255, 255, 255, 0.05);
}

.theme-button {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
padding: 0.75rem 1rem;
color: inherit;
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: transform 140ms ease, background 140ms ease;
}

.theme-button:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.1);
}

.counter-card {
display: grid;
gap: 0.65rem;
Expand Down Expand Up @@ -202,6 +240,41 @@ input {
color: #ff9f8f;
}

.shell[data-theme="light"] {
color: #281d14;
}

.shell[data-theme="light"] .panel {
border-color: rgba(103, 75, 41, 0.14);
background: linear-gradient(180deg, rgba(255, 249, 241, 0.97), rgba(240, 230, 216, 0.92));
box-shadow: 0 1rem 3rem rgba(68, 45, 21, 0.12);
}

.shell[data-theme="light"] .lede,
.shell[data-theme="light"] .panel-head p,
.shell[data-theme="light"] .counter-label,
.shell[data-theme="light"] .field-label,
.shell[data-theme="light"] .status {
color: rgba(40, 29, 20, 0.68);
}

.shell[data-theme="light"] .theme-chip,
.shell[data-theme="light"] .counter-card,
.shell[data-theme="light"] .post,
.shell[data-theme="light"] .post-input {
border-color: rgba(103, 75, 41, 0.12);
background: rgba(109, 82, 51, 0.07);
}

.shell[data-theme="light"] .theme-button {
border-color: rgba(103, 75, 41, 0.18);
background: rgba(109, 82, 51, 0.08);
}

.shell[data-theme="light"] .post-id {
color: #b16a29;
}

@media (min-width: 860px) {
.shell {
grid-template-columns: 1.2fr 0.8fr;
Expand Down
15 changes: 15 additions & 0 deletions apps/demo/src/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext, createProvider, type Accessor } from "@sarthakdev143/shadejs";

export type ThemeMode = "dark" | "light";

export interface ThemeContextValue {
theme: Accessor<ThemeMode>;
toggleTheme: () => void;
}

export const ThemeContext = createContext<ThemeContextValue>({
theme: () => "dark",
toggleTheme: () => {}
});

export const ThemeProvider = createProvider(ThemeContext);
47 changes: 47 additions & 0 deletions packages/compiler/src/analyzer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { parse, type ParserPlugin } from "@babel/parser";
import traverseImport, { type NodePath } from "@babel/traverse";
import {
isArrowFunctionExpression,
isExportNamedDeclaration,
isFunctionDeclaration,
isFunctionExpression,
isIdentifier,
isImportDefaultSpecifier,
isImportNamespaceSpecifier,
isImportSpecifier,
isVariableDeclaration,
type ImportDeclaration,
type ImportDefaultSpecifier,
type ImportSpecifier
Expand Down Expand Up @@ -62,3 +67,45 @@ export function analyzeServerImports(source: string): ServerImport[] {

return imports;
}

export function extractExportedFunctions(source: string): string[] {
const ast = parse(source, {
plugins: parserPlugins,
sourceType: "module"
});
const exportedFunctions = new Set<string>();

traverse(ast, {
ExportNamedDeclaration(path) {
if (!isExportNamedDeclaration(path.node) || path.node.declaration === null) {
return;
}

if (isFunctionDeclaration(path.node.declaration)) {
const functionId = path.node.declaration.id;

if (functionId !== null && functionId !== undefined) {
exportedFunctions.add(functionId.name);
}

return;
}

if (!isVariableDeclaration(path.node.declaration)) {
return;
}

for (const declarator of path.node.declaration.declarations) {
if (
isIdentifier(declarator.id) &&
declarator.init !== null &&
(isArrowFunctionExpression(declarator.init) || isFunctionExpression(declarator.init))
) {
exportedFunctions.add(declarator.id.name);
}
}
}
});

return [...exportedFunctions];
}
22 changes: 22 additions & 0 deletions packages/compiler/src/hmr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function generateHMRBlock(componentExports: string[]): string {
if (componentExports.length === 0) {
return "";
}

const updates = componentExports
.map(
(name) => `
if (newModule.${name} && window.__shadejs_registry__?.has("${name}")) {
window.__shadejs_registry__.get("${name}")(newModule.${name});
}`
)
.join("");

return `
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
if (!newModule) return${updates}
});
}
`;
}
3 changes: 2 additions & 1 deletion packages/compiler/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { analyzeServerImports, isServerImportPath } from "./analyzer";
export { analyzeServerImports, extractExportedFunctions, isServerImportPath } from "./analyzer";
export type { ServerImport } from "./analyzer";
export { generateHMRBlock } from "./hmr";
export { shadejs } from "./plugin";
export { generateRPCStub, getRPCRoutePath } from "./rpc-gen";
export { generateProductionServer } from "./server-build";
Expand Down
16 changes: 13 additions & 3 deletions packages/compiler/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";

import type { Plugin, ViteDevServer } from "vite";

import { analyzeServerImports } from "./analyzer";
import { analyzeServerImports, extractExportedFunctions } from "./analyzer";
import { generateHMRBlock } from "./hmr";
import { getRPCRoutePath } from "./rpc-gen";
import { generateProductionServer } from "./server-build";
import { transformServerImports } from "./transform";
Expand Down Expand Up @@ -116,6 +117,7 @@ function installRpcMiddleware(server: ViteDevServer, routeRegistry: Map<string,
}

export function shadejs(): Plugin {
let command: "build" | "serve" = "build";
const routeRegistry = new Map<string, string>();

return {
Expand All @@ -128,6 +130,9 @@ export function shadejs(): Plugin {
mkdirSync(outDir, { recursive: true });
writeFileSync(resolve(outDir, "server.mjs"), generateProductionServer(routeRegistry), "utf8");
},
configResolved(config) {
command = config.command;
},
configureServer(server) {
installRpcMiddleware(server, routeRegistry);
},
Expand All @@ -149,12 +154,17 @@ export function shadejs(): Plugin {
}

const transformed = transformServerImports(code);
const nextCode = transformed?.code ?? code;
const hmrBlock = command === "serve" ? generateHMRBlock(extractExportedFunctions(nextCode)) : "";

if (transformed === null) {
if (transformed === null && hmrBlock.length === 0) {
return null;
}

return transformed;
return {
code: `${nextCode}${hmrBlock.length > 0 ? `\n${hmrBlock.trimStart()}` : ""}`,
map: transformed?.map ?? null
};
}
};
}
Loading
Loading